From b63efeb0d3719f7cf21f9d641a5eb011f989c728 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 13 Oct 2026 15:12:32 +0530 Subject: [PATCH 001/185] perf(project): read file sizes once per sort --- crates/ui-egui/src/i18n/ui.rs | 14 +++ crates/ui-egui/src/panels/project.rs | 158 +++++++++++++++++++++++- crates/ui-egui/tests/ui_project_rows.rs | 102 +++++++++++++++ scripts/bench-project-size-sort.sh | 4 + 4 files changed, 276 insertions(+), 2 deletions(-) create mode 100755 scripts/bench-project-size-sort.sh diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4af..d61eff44 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -252,6 +252,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Focus Distance", "フォーカス距離"), ("Follow Feature Rotation and Scale", "特徴の回転とスケールに追従"), ("Footage: {}", "フッテージ:{}"), + ("File size: {} bytes", "ファイルサイズ: {} バイト"), + ("File size unavailable", "ファイルサイズを取得できません"), ("Format: {}", "形式:{}"), ("Frame Aspect Ratio: {}", "フレームアスペクト比:{}"), ("Frame Blending: {}", "フレームブレンド:{}"), @@ -796,6 +798,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Focus Distance", "焦距"), ("Follow Feature Rotation and Scale", "跟随特征的旋转和缩放"), ("Footage: {}", "素材:{}"), + ("File size: {} bytes", "文件大小:{} 字节"), + ("File size unavailable", "无法获取文件大小"), ("Format: {}", "格式:{}"), ("Frame Aspect Ratio: {}", "帧长宽比:{}"), ("Frame Blending: {}", "帧混合:{}"), @@ -1528,6 +1532,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Focus Distance", "焦距"), ("Follow Feature Rotation and Scale", "跟隨特徵的旋轉和縮放"), ("Footage: {}", "素材:{}"), + ("File size: {} bytes", "檔案大小:{} 位元組"), + ("File size unavailable", "無法取得檔案大小"), ("Format: {}", "格式:{}"), ("Frame Aspect Ratio: {}", "影格長寬比:{}"), ("Frame Blending: {}", "影格混合:{}"), @@ -2263,6 +2269,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Focus Distance", "Відстань фокусування"), ("Follow Feature Rotation and Scale", "Враховувати обертання та масштаб ознаки"), ("Footage: {}", "Матеріал: {}"), + ("File size: {} bytes", "Розмір файлу: {} байт"), + ("File size unavailable", "Розмір файлу недоступний"), ("Format: {}", "Формат: {}"), ("Frame Aspect Ratio: {}", "Пропорції кадру: {}"), ("Frame Blending: {}", "Змішування кадрів: {}"), @@ -2815,6 +2823,8 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Focus Distance", "Distância de foco"), ("Follow Feature Rotation and Scale", "Acompanhar rotação e escala do recurso"), ("Footage: {}", "Mídia: {}"), + ("File size: {} bytes", "Tamanho do arquivo: {} bytes"), + ("File size unavailable", "Tamanho do arquivo indisponível"), ("Format: {}", "Formato: {}"), ("Frame Aspect Ratio: {}", "Proporção do quadro: {}"), ("Frame Blending: {}", "Mesclagem de quadros: {}"), @@ -3265,6 +3275,8 @@ const CZECH: &[(&str, &str)] = &[ ("Focus Distance", "Vzdálenost zaostření"), ("Follow Feature Rotation and Scale", "Sledovat otočení a měřítko prvku"), ("Footage: {}", "Záznam: {}"), + ("File size: {} bytes", "Velikost souboru: {} bajtů"), + ("File size unavailable", "Velikost souboru není k dispozici"), ("Format: {}", "Formát: {}"), ("Frame Aspect Ratio: {}", "Poměr stran snímku: {}"), ("Frame Blending: {}", "Prolnutí snímků: {}"), @@ -3820,6 +3832,8 @@ const SPANISH: &[(&str, &str)] = &[ ("Focus Distance", "Distancia de enfoque"), ("Follow Feature Rotation and Scale", "Seguir la rotación y la escala de la característica"), ("Footage: {}", "Material de archivo: {}"), + ("File size: {} bytes", "Tamaño del archivo: {} bytes"), + ("File size unavailable", "Tamaño del archivo no disponible"), ("Format: {}", "Formato: {}"), ("Frame Aspect Ratio: {}", "Relación de aspecto del fotograma: {}"), ("Frame Blending: {}", "Fusión de fotogramas: {}"), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8..2cb6b8c7 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -84,11 +84,14 @@ pub const COLUMNS: [(&str, &str, f32); 6] = [ /// Sort siblings by the Project panel's sort column; ties fall back to the name. pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) { + if col == "size" { + sort_by_size(items, desc, file_size); + return; + } let name = |i: &Item| i.name.to_lowercase(); items.sort_by(|a, b| { let o = match col { "type" => a.type_name().cmp(b.type_name()), - "size" => file_size(a).cmp(&file_size(b)), "fps" => a.frame_rate().map(|r| r.as_f64()).partial_cmp(&b.frame_rate().map(|r| r.as_f64())).unwrap_or(std::cmp::Ordering::Equal), "duration" => a.duration().cmp(&b.duration()), "path" => file_path(a).cmp(file_path(b)), @@ -101,6 +104,15 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) { }); } +fn sort_by_size(items: &mut [&Item], desc: bool, mut size_of: impl FnMut(&Item) -> Option) { + let mut key = |it: &&Item| (size_of(it), it.name.to_lowercase()); + if desc { + items.sort_by_cached_key(|it| std::cmp::Reverse(key(it))); + } else { + items.sort_by_cached_key(key); + } +} + /// Where a drop at a row lands: into a folder row, else into the row's folder; `None` row = root. pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option) -> Option { let it = project.item(row?)?; @@ -754,7 +766,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cp = lp.with_clip_rect(cell); let txt = match *key { "type" => it.type_name().to_string(), - "size" => file_size(it).map(fmt_size).unwrap_or_default(), + "size" => { + let size = file_size(it); + let text = size.map(fmt_size).unwrap_or_else(|| if matches!(it.kind, ItemKind::Footage(_)) { "—".into() } else { String::new() }); + if matches!(it.kind, ItemKind::Footage(_)) { + let detail = match size { + Some(bytes) => crate::i18n::tr_args("File size: {} bytes", &[&bytes]), + None => crate::i18n::tr("File size unavailable").to_string(), + }; + let resp = ui.interact(cell, egui::Id::new(("psize", id.0)), Sense::hover()).on_hover_text(detail.as_str()); + app.auto.add_response(&format!("project.item.{}.size", id.0), &resp, &detail); + } + text + } "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(), "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(), "path" => file_path(it).to_string(), @@ -1113,6 +1137,136 @@ pub fn set_column(cols: &mut Vec, key: &str, on: bool) { #[cfg(test)] mod tests { use super::*; + use effectcraft_engine::project::{Footage, Project}; + + struct SizeFiles(std::path::PathBuf); + + impl SizeFiles { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("effectcraft-size-{}-{n}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn add(&self, project: &mut Project, name: &str, bytes: Option) { + let path = self.0.join(name); + if let Some(bytes) = bytes { + std::fs::File::create(&path).unwrap().set_len(bytes).unwrap(); + } + project.add_item(name, Label::Aqua, None, ItemKind::Footage(Footage { path: path.to_string_lossy().into_owned(), ..Default::default() })); + } + } + + impl Drop for SizeFiles { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn size_keys_are_sampled_once_with_stable_ties() { + let mut project = Project::default(); + for name in ["Zeta", "alpha", "ALPHA", "Missing", "Zero"] { + project.add_item(name, Label::Aqua, None, ItemKind::Folder); + } + for (desc, expected) in [(false, ["Missing", "Zero", "alpha", "ALPHA", "Zeta"]), (true, ["Zeta", "alpha", "ALPHA", "Zero", "Missing"])] { + let mut reads = std::collections::BTreeMap::new(); + let mut rows = project.children(None); + sort_by_size(&mut rows, desc, |item| { + *reads.entry(item.id).or_insert(0) += 1; + match item.name.as_str() { + "Zeta" => Some(30), + "alpha" | "ALPHA" => Some(10), + "Zero" => Some(0), + _ => None, + } + }); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), expected); + assert_eq!(reads.len(), 5); + assert!(reads.values().all(|&count| count == 1)); + } + } + + #[test] + fn size_sort_reads_files_again_after_they_change() { + let files = SizeFiles::new(); + let mut project = Project::default(); + project.add_item("Folder", Label::Aqua, None, ItemKind::Folder); + files.add(&mut project, "Missing", None); + files.add(&mut project, "Zero", Some(0)); + files.add(&mut project, "Small", Some(10)); + files.add(&mut project, "Large", Some(20)); + let mut rows = project.children(None); + sort_items(&mut rows, "size", false); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), ["Folder", "Missing", "Zero", "Small", "Large"]); + sort_items(&mut rows, "size", true); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), ["Large", "Small", "Zero", "Missing", "Folder"]); + std::fs::OpenOptions::new().write(true).open(files.0.join("Small")).unwrap().set_len(30).unwrap(); + sort_items(&mut rows, "size", false); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), ["Folder", "Missing", "Zero", "Large", "Small"]); + std::fs::remove_file(files.0.join("Large")).unwrap(); + sort_items(&mut rows, "size", false); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), ["Folder", "Large", "Missing", "Zero", "Small"]); + } + + #[test] + fn size_sort_accepts_empty_and_singleton_lists() { + let mut project = Project::default(); + let id = project.add_item("Only", Label::Aqua, None, ItemKind::Folder); + for desc in [false, true] { + sort_items(&mut [], "size", desc); + let mut rows = project.children(None); + sort_items(&mut rows, "size", desc); + assert_eq!(rows.iter().map(|item| item.id).collect::>(), [id]); + } + } + + #[test] + #[ignore = "filesystem benchmark; run scripts/bench-project-size-sort.sh"] + fn size_sort_benchmark() { + let files = SizeFiles::new(); + let mut project = Project::default(); + for i in 0..1000 { + files.add(&mut project, &format!("Clip {i:04}"), Some((i * 137) % 1000 + 1)); + } + let original = project.children(None); + for item in &original { + assert!(file_size(item).is_some()); + } + let mut before_times = Vec::new(); + let mut after_times = Vec::new(); + let mut before_reads = 0; + let mut after_reads = 0; + for _ in 0..5 { + let mut before = original.clone(); + before_reads = 0; + let start = std::time::Instant::now(); + before.sort_by(|a, b| { + before_reads += 2; + file_size(a).cmp(&file_size(b)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + before_times.push(start.elapsed()); + let mut after = original.clone(); + after_reads = 0; + let start = std::time::Instant::now(); + sort_by_size(&mut after, false, |item| { + after_reads += 1; + file_size(item) + }); + after_times.push(start.elapsed()); + assert_eq!(before.iter().map(|item| item.id).collect::>(), after.iter().map(|item| item.id).collect::>()); + assert_eq!(after_reads, 1000); + assert!(before_reads > after_reads); + } + before_times.sort(); + after_times.sort(); + println!( + "1,000 footage files; warmed metadata; five paired sorts\nmetadata reads/sort: {before_reads} -> {after_reads}\nmedian sort time: {:?} -> {:?}", + before_times[2], after_times[2] + ); + } #[test] fn ae_duration_and_size_formats() { diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 27ce5e69..949310f5 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -84,6 +84,108 @@ fn command_clicking_a_folder_twirl_opens_the_folders_inside_it() { assert!(h.state().ui.project_open_folders.is_empty(), "and close again together"); } +#[test] +fn size_column_sorts_and_explains_sizes_without_swallowing_row_clicks() { + use effectcraft_ui_egui::theme::ThemeKind; + for (name, size, theme) in [ + ("wide-dark", egui::vec2(1600.0, 1000.0), ThemeKind::Dark), + ("compact-dark", egui::vec2(1024.0, 768.0), ThemeKind::Dark), + ("compact-light", egui::vec2(1024.0, 768.0), ThemeKind::Light), + ] { + check_size_column(name, size, theme); + } +} + +fn check_size_column(name: &str, size: egui::Vec2, theme: effectcraft_ui_egui::theme::ThemeKind) { + struct Files(std::path::PathBuf); + impl Drop for Files { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let files = Files(std::env::temp_dir().join(format!( + "effectcraft-size-ui-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + ))); + std::fs::create_dir(&files.0).unwrap(); + let mut s = Session::default(); + s.prefs.general.language = "en".into(); + let project = std::sync::Arc::make_mut(&mut s.project); + let folder = project.add_item("Folder", Label::Aqua, None, ItemKind::Folder); + let mut ids = Vec::new(); + for (name, bytes) in [("Small", Some(10)), ("Large", Some(20)), ("Unavailable", None), ("Empty", Some(0))] { + let path = files.0.join(name); + if let Some(bytes) = bytes { + std::fs::File::create(&path).unwrap().set_len(bytes).unwrap(); + } + ids.push(project.add_item(name, Label::Aqua, None, ItemKind::Footage(Footage { path: path.to_string_lossy().into_owned(), ..Default::default() }))); + } + let [small, large, unavailable, empty] = ids.as_slice() else { panic!("fixture items") }; + let (small, large, unavailable, empty) = (*small, *large, *unavailable, *empty); + let mut app = EffectcraftApp::new(s); + app.ui.start_screen = false; + app.ui.project_columns = vec!["size".into()]; + let mut h = Harness::builder().with_size(size).build_eframe(|_| app); + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(3); + let size_id = |id: ItemId| format!("project.item.{}.size", id.0); + let assert_order = |h: &Harness<'_, EffectcraftApp>, expected: &[ItemId]| { + let mut visible = expected.to_vec(); + visible.sort_by(|a, b| center(h, &format!("project.item.{}", a.0)).y.total_cmp(¢er(h, &format!("project.item.{}", b.0)).y)); + assert_eq!(visible, expected); + }; + let undo = h.state().session.history.undo.len(); + click(&mut h, "project.sort.size"); + assert_order(&h, &[folder, unavailable, empty, small, large]); + click(&mut h, "project.sort.size"); + assert_order(&h, &[large, small, empty, unavailable, folder]); + assert_eq!(h.state().auto.find(&size_id(empty)).unwrap().label, "File size: 0 bytes"); + assert!(h.state().auto.find(&size_id(folder)).is_none()); + let at = center(&h, &size_id(small)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(5); + assert!(h.query_by_label("File size: 10 bytes").is_some(), "hover exposes exact bytes when both files display 1 KB"); + if let Ok(dir) = std::env::var("PROJECT_SIZE_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/project-size-{name}-hover.png")).unwrap(); + } + click_at(&mut h, at); + assert_eq!(h.state().session.state.project_selection, [small]); + let at = center(&h, &size_id(unavailable)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(5); + assert!(h.query_by_label("File size unavailable").is_some()); + let has_dash = |shape: &egui::Shape| matches!(shape, egui::Shape::Text(text) if text.galley.text() == "—"); + assert!(h.output().shapes.iter().any(|shape| has_dash(&shape.shape)), "unavailable footage has a visible marker"); + if let Ok(dir) = std::env::var("PROJECT_SIZE_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/project-size-{name}-unavailable.png")).unwrap(); + } + std::fs::OpenOptions::new().write(true).open(files.0.join("Small")).unwrap().set_len(30).unwrap(); + h.run_steps(3); + assert_order(&h, &[small, large, empty, unavailable, folder]); + assert_eq!(h.state().auto.find(&size_id(small)).unwrap().label, "File size: 30 bytes"); + assert_eq!(h.state().session.history.undo.len(), undo, "sorting and size details don't edit the project"); + let from = center(&h, &size_id(small)); + let to = center(&h, &format!("project.item.{}.name", folder.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + button(&mut h, from, true); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + button(&mut h, to, false); + h.run_steps(3); + assert_eq!(h.state().session.project.item(small).unwrap().parent, Some(folder), "dragging from Size still moves the item"); + assert_eq!(h.state().session.history.undo.len(), undo + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.project.item(small).unwrap().parent, None); + assert_order(&h, &[small, large, empty, unavailable, folder]); +} + #[test] fn folder_twirl_opens_and_closes_the_folder_on_one_click() { let (mut h, folder, comp, _) = harness(); diff --git a/scripts/bench-project-size-sort.sh b/scripts/bench-project-size-sort.sh new file mode 100755 index 00000000..b85e495d --- /dev/null +++ b/scripts/bench-project-size-sort.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +CARGO_PROFILE_RELEASE_LTO=false cargo test -p effectcraft-ui-egui --release --lib panels::project::tests::size_sort_benchmark -- --ignored --nocapture From 058da6f3b053e42161d73670300eaffbd7b93723 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 10:09:51 +0000 Subject: [PATCH 002/185] CI: use a match guard in the STL reader --- crates/model/src/stl.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858a..34e9a7f0 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` From 037a21d5a6330a47b338daad0109f6a09419249f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 10:09:51 +0000 Subject: [PATCH 003/185] #1200: allow TGA proxies and add import regressions --- contributors/people.toml | 2 +- crates/host/tests/tga_import.rs | 48 +++++++++++++ crates/media/tests/tga.rs | 115 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 19 +++++- 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 crates/host/tests/tga_import.rs create mode 100644 crates/media/tests/tga.rs diff --git a/contributors/people.toml b/contributors/people.toml index 705feb25..0de777b1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -66,7 +66,7 @@ sync_github_name = true display_name = "Jason Brashear" [people.nanox333] -display_name = "nanox333" +display_name = "nnaannoo" [people.Nael-Nathanael] sync_github_name = true diff --git a/crates/host/tests/tga_import.rs b/crates/host/tests/tga_import.rs new file mode 100644 index 00000000..5462f4be --- /dev/null +++ b/crates/host/tests/tga_import.rs @@ -0,0 +1,48 @@ +//! Targa import through the same host and commands used by the desktop and CLI. + +use effectcraft_engine::render::RenderOpts; +use effectcraft_project::{FootageKind, ItemId, ItemKind}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +#[test] +fn tga_stills_and_numbered_sequences_import_and_render() { + let dir = std::env::temp_dir().join(format!("effectcraft-tga-import-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A generated 4×4, 24-bit, top-origin TGA. No binary fixtures are retained. + let write = |name: &str, bgr: [u8; 3]| { + let mut bytes = vec![0; 18]; + bytes[2] = 2; + bytes[12] = 4; + bytes[14] = 4; + bytes[16] = 24; + bytes[17] = 0x20; + bytes.extend_from_slice(&bgr.repeat(16)); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().to_string() + }; + let still = write("still.TGA", [0, 0, 255]); + let first = write("shot_0001.tga", [0, 0, 255]); + write("shot_0002.tga", [255, 0, 0]); + for (path, sequence) in [(still, false), (first, true)] { + let mut session = effectcraft_host::session(); + let imported = session.execute_checked("file.import", json!({"paths": [path], "sequence": sequence})).unwrap(); + assert_eq!(imported["errors"], json!([]), "{imported}"); + assert_eq!(imported["items"].as_array().unwrap().len(), 1); + let item = ItemId(imported["items"][0].as_u64().unwrap()); + let ItemKind::Footage(footage) = &session.project.item(item).unwrap().kind else { panic!("not footage") }; + assert_eq!(footage.kind, if sequence { FootageKind::Sequence } else { FootageKind::Still }); + assert_eq!(footage.sequence.len(), if sequence { 2 } else { 0 }); + session.execute_checked("comp.new", json!({"name": "TGA", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap(); + session.execute_checked("layer.addItem", json!({"item": item.0})).unwrap(); + let comp = session.active_comp_id().unwrap(); + let image = session.render(comp, Tick::ZERO, RenderOpts::default()); + assert_eq!(image.get(2, 2), [1.0, 0.0, 0.0, 1.0]); + if sequence { + let image = session.render(comp, FrameRate::FPS_30.tick_of(1), RenderOpts::default()); + assert_eq!(image.get(2, 2), [0.0, 0.0, 1.0, 1.0]); + } + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/media/tests/tga.rs b/crates/media/tests/tga.rs new file mode 100644 index 00000000..61aacd95 --- /dev/null +++ b/crates/media/tests/tga.rs @@ -0,0 +1,115 @@ +//! Tiny synthetic Targa files exercise import without external fixtures or codecs. + +use std::path::Path; +use std::sync::Arc; + +use effectcraft_media::{MediaPool, is_importable, probe_bytes}; +use effectcraft_project::{AlphaMode, Footage, FootageKind}; +use effectcraft_time::Tick; + +/// A classic 2×2 TGA without a footer. True-colour pixels are stored BGR(A); RLE +/// packets repeat each row's colour twice. `pixels` is always in top-down RGBA order. +fn tga(pixels: [[u8; 4]; 4], alpha: bool, rle: bool, top: bool) -> Vec { + let mut bytes = vec![0; 18]; + bytes[2] = if rle { 10 } else { 2 }; + bytes[12] = 2; + bytes[14] = 2; + bytes[16] = if alpha { 32 } else { 24 }; + bytes[17] = if top { 0x20 } else { 0 } | if alpha { 8 } else { 0 }; + let order = if top { [0, 1, 2, 3] } else { [2, 3, 0, 1] }; + for row in order.chunks_exact(2) { + if rle { + assert_eq!(pixels[row[0]], pixels[row[1]]); + bytes.push(0x81); // A two-pixel run. + } + for &i in row.iter().take(if rle { 1 } else { 2 }) { + let [r, g, b, a] = pixels[i]; + bytes.extend_from_slice(&[b, g, r]); + if alpha { + bytes.push(a); + } + } + } + bytes +} + +#[test] +fn tga_raw_and_rle_preserve_colours_orientation_and_alpha() { + for alpha in [false, true] { + for rle in [false, true] { + for top in [false, true] { + let pixels = if rle { + [[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 255], [0, 0, 255, 255]] + } else { + [[255, 0, 0, 128], [0, 255, 0, 255], [0, 0, 255, 64], [255, 255, 255, 0]] + }; + let path = if top { "/frame.TGA" } else { "/frame.tga" }; + assert!(is_importable(Path::new(path))); + let bytes: Arc<[u8]> = tga(pixels, alpha, rle, top).into(); + let footage = probe_bytes(path, bytes.clone()).unwrap(); + assert_eq!(footage.kind, FootageKind::Still); + assert_eq!((footage.width, footage.height), (2, 2)); + assert_eq!(footage.codec, "TGA"); + assert_eq!(footage.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }); + assert!(footage.has_video && !footage.has_audio); + let pool = MediaPool::new(); + pool.add_bytes(path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + for (actual, [r, g, b, a]) in image.data.iter().zip(pixels) { + let a = if alpha { f32::from(a) / 255.0 } else { 1.0 }; + let expected = [f32::from(r) / 255.0 * a, f32::from(g) / 255.0 * a, f32::from(b) / 255.0 * a, a]; + assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{actual:?} != {expected:?}"); + } + } + } + } +} + +#[test] +fn tga_sequence_decodes_each_frame_from_memory() { + let pool = MediaPool::new(); + pool.set_read_ahead(false); + let paths = ["/shot_0001.tga", "/shot_0002.tga"]; + let colours = [[255, 0, 0, 255], [0, 0, 255, 255]]; + let mut footage = None; + for (path, colour) in paths.iter().zip(colours) { + let bytes: Arc<[u8]> = tga([colour; 4], false, true, true).into(); + footage.get_or_insert_with(|| probe_bytes(path, bytes.clone()).unwrap()); + pool.add_bytes(path, bytes); + } + let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.iter().map(|s| s.to_string()).collect(), ..footage.unwrap() }; + footage.sync_sequence_duration(); + for (n, expected) in [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]].into_iter().enumerate() { + let image = pool.frame_at(&footage, footage.frame_rate.tick_of(n as i64)).unwrap(); + assert_eq!(image.get(0, 0), expected); + } +} + +#[test] +fn malformed_tga_returns_contextual_errors() { + for bytes in [vec![], vec![0; 17], vec![0; 18]] { + let error = probe_bytes("/broken.tga", bytes.into()).unwrap_err().to_string(); + assert!(error.contains("/broken.tga"), "{error}"); + } + let mut bytes = tga([[255, 0, 0, 255]; 4], true, false, true); + bytes.truncate(19); // Valid metadata, truncated pixel data. + let bytes: Arc<[u8]> = bytes.into(); + let footage = probe_bytes("/truncated.tga", bytes.clone()).unwrap(); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let error = pool.frame_at(&footage, Tick::ZERO).unwrap_err().to_string(); + assert!(error.contains("/truncated.tga"), "{error}"); +} + +#[test] +fn a_recognised_signature_takes_precedence_over_the_tga_extension() { + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(3, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let bytes: Arc<[u8]> = bytes.into_inner().into(); + let footage = probe_bytes("/actually-png.tga", bytes.clone()).unwrap(); + assert_eq!(footage.codec, "PNG"); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + assert_eq!((image.width, image.height), (3, 2)); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c3..53783859 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1361,7 +1361,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => { + ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])) + } _ => return None, }; if params.get(key).is_some() @@ -2494,6 +2496,21 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn tga_is_available_in_import_replace_and_proxy_dialogs() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + let pending = Arc::new(Mutex::new(Vec::new())); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + for command in ["file.import", "file.importMultiple", "file.replaceFootage", "file.setProxy"] { + assert_eq!(invoke(&mut app, &ctx, command, json!({})).unwrap(), json!({"dialog": command})); + let crate::FileDialog::Open { exts, .. } = pending.lock().unwrap().pop().unwrap() else { panic!("not an open dialog") }; + assert!(exts.iter().any(|ext| ext == "tga"), "{command}: {exts:?}"); + } + } + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help")); From 2ddbf20466e79b71e53dc565d1bb0b22c814e926 Mon Sep 17 00:00:00 2001 From: Moein Saharkhiz <142007707+moecr7@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:31:17 -0400 Subject: [PATCH 004/185] #1070: refresh layered composition sources on import --- crates/engine/src/commands/file.rs | 7 +++ crates/host/tests/footage_reload.rs | 77 +++++++++++++++++++++++++++++ docs/footage.md | 3 ++ 3 files changed, 87 insertions(+) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 3b43eb63..0b131ec7 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -415,6 +415,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { } } paths = rest; + // Composition imports already made their sources, so they bypass add_footage's + // probed-footage refresh. Refresh them before any background probe job starts (#1070). + for id in &items { + if let Some(ItemKind::Footage(f)) = s.project.item(ItemId(*id)).map(|it| &it.kind) { + s.forget_decoded(f); + } + } } // Numbered stills as image sequences (the Import dialog's " Sequence" and Force // Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage. diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/footage_reload.rs index 6834fc54..faf2b09e 100644 --- a/crates/host/tests/footage_reload.rs +++ b/crates/host/tests/footage_reload.rs @@ -213,3 +213,80 @@ fn a_new_project_importing_an_overwritten_file_shows_the_new_pixels() { let cid = s.active_comp_id().unwrap(); assert!(near(pixel(&s, cid, 32, 24), BLUE), "stale pixels from the earlier project: {:?}", pixel(&s, cid, 32, 24)); } + +fn layered_comp(s: &mut Session, path: &str, import_as: &str) -> ItemId { + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap(); + ItemId(r["comps"][0].as_u64().unwrap()) +} + +/// Composition imports create their sources before probing ordinary footage (#1070). +#[test] +fn fresh_layered_composition_import_refreshes_its_sources() { + for import_as in ["composition", "compositionLayerSizes"] { + let path = tmp(&format!("fresh-{import_as}.psd")); + let (red, blue) = (flat(RED, false), flat(BLUE, false)); + assert_eq!(red.len(), blue.len(), "the overwritten file has the same byte size"); + std::fs::write(&path, red).unwrap(); + let mut s = effectcraft_host::session(); + let old_comp = layered_comp(&mut s, &path, import_as); + assert!(near(pixel(&s, old_comp, 32, 24), RED)); + s.execute_checked("file.newProject", json!({})).unwrap(); + save(&path, &blue); + s.drain_events(); + let comp = layered_comp(&mut s, &path, import_as); + assert_eq!(comp, old_comp, "new projects reuse their item ids"); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "layered import showed {:?}", pixel(&s, comp, 32, 24)); + std::fs::remove_file(path).unwrap(); + } +} + +#[test] +fn layered_reimport_refreshes_existing_layers_and_keeps_unrelated_decoded_files() { + let path = tmp("layered-reimport.psd"); + let other = tmp("layered-unchanged.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + std::fs::write(&other, flat(GREEN, false)).unwrap(); + let mut s = effectcraft_host::session(); + let comp = layered_comp(&mut s, &path, "compositionLayerSizes"); + let r = s.execute_checked("file.import", json!({"paths": [&other]})).unwrap(); + let other_item = ItemId(r["items"][0].as_u64().unwrap()); + let other_footage = footage(&s, other_item); + let cached = s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap(); + assert!(near(pixel(&s, comp, 32, 24), RED)); + let before = s.project.clone(); + let undo = s.history.undo.len(); + save(&path, &flat(BLUE, false)); + s.drain_events(); + layered_comp(&mut s, &path, "compositionLayerSizes"); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "existing layers see the refreshed source"); + assert!(std::sync::Arc::ptr_eq(&cached, &s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap())); + assert_eq!(s.history.undo.len(), undo + 1, "composition import stays one undo step"); + s.undo(); + assert!(std::sync::Arc::ptr_eq(&s.project, &before)); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "undo does not undo an external file write"); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(other).unwrap(); +} + +#[test] +fn background_import_refreshes_layered_sources_before_the_probe_job_finishes() { + let path = tmp("layered-background.psd"); + let other = tmp("layered-background.png"); + std::fs::write(&path, flat(RED, false)).unwrap(); + image::RgbaImage::from_pixel(64, 48, image::Rgba([0, 255, 0, 255])).save(&other).unwrap(); + let mut s = effectcraft_host::session(); + let comp = layered_comp(&mut s, &path, "compositionLayerSizes"); + assert!(near(pixel(&s, comp, 32, 24), RED)); + save(&path, &flat(BLUE, false)); + s.drain_events(); + let r = s.execute_checked("file.import", json!({"paths": [&path, &other], "importAs": "compositionLayerSizes", "background": true})).unwrap(); + assert_eq!(r["started"], true, "ordinary footage is probed in a background job"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "composition sources are refreshed before the job is applied"); + s.wait_jobs(); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, s.active_comp_id().unwrap(), 32, 24), BLUE)); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(other).unwrap(); +} diff --git a/docs/footage.md b/docs/footage.md index 4a5a498c..84d4c49e 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -107,6 +107,9 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la ## Replacing and reloading +Importing a layered file as a composition refreshes the decoded sources used by its layers, +including files overwritten at the same path and byte size. + - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, "sequence": false}` for one frame). From da06216475cf3e7ea5aaedf40169220f6e7aa824 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 13 Oct 2026 16:48:19 +0530 Subject: [PATCH 005/185] perf(presets): reuse cached text and user preset data --- crates/engine/src/commands/text_anim.rs | 9 ++- crates/engine/src/tests_text.rs | 58 ++++++++++++++ crates/ui-egui/src/panels/effects_presets.rs | 83 +++++++++++++++++--- crates/ui-egui/tests/ui_presets_folder.rs | 75 +++++++++++++++++- 4 files changed, 209 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 228d4842..92ab23f1 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -6,6 +6,7 @@ use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Layer, LayerSource, Node, PropGroup}; use effectcraft_time::Tick; use serde_json::{Value, json}; +use std::sync::OnceLock; use super::{CommandSpec, b_p, bad, has_comp, layer_mut, layer_p, str_p}; use crate::{Result, Session, cmd, query}; @@ -13,8 +14,10 @@ use crate::{Result, Session, cmd, query}; /// The original text animator presets (`presets/text_animators.json`). pub const PRESETS_JSON: &str = include_str!("../../presets/text_animators.json"); -fn presets() -> Vec { - serde_json::from_str::(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default() +fn presets() -> &'static [Value] { + static PRESETS: OnceLock> = OnceLock::new(); + PRESETS + .get_or_init(|| serde_json::from_str::(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()) } /// (id, name) of every preset. @@ -303,7 +306,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, C)?; let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string(); let preset = presets() - .into_iter() + .iter() .find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want))) .ok_or_else(|| bad(C, format!("no preset {want}")))?; let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string(); diff --git a/crates/engine/src/tests_text.rs b/crates/engine/src/tests_text.rs index 7b965006..a57f87a7 100644 --- a/crates/engine/src/tests_text.rs +++ b/crates/engine/src/tests_text.rs @@ -164,6 +164,64 @@ fn presets_apply_render_and_round_trip() { assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string())); } +#[test] +fn cached_text_presets_keep_the_catalog_and_application_independent() { + let expected = [ + ("typewriter", "Typewriter"), + ("fadeUpCharacters", "Fade Up Characters"), + ("bounceInWords", "Bounce In Words"), + ("trackingIn", "Tracking In"), + ("scramble", "Scramble"), + ("blurIn", "Blur In"), + ("jitter", "Jitter"), + ("dropInLines", "Drop In Lines"), + ]; + let (mut s, layer) = session(); + let catalog = s.execute("text.presets", json!({})).unwrap(); + let mut returned = catalog.clone(); + returned[0]["name"] = json!("Edited copy"); + let project = s.project.clone(); + for _ in 0..2 { + let names = crate::text_presets(); + assert_eq!(names.iter().map(|(id, name)| (id.as_str(), name.as_str())).collect::>(), expected); + for (id, _) in expected { + let result = s.execute("layer.applyTextPreset", json!({"layer": layer, "preset": id})).unwrap(); + assert!(!result["animators"].as_array().unwrap().is_empty(), "{id}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, project, "{id}: undo restores the document"); + } + assert_eq!(s.execute("text.presets", json!({})).unwrap(), catalog); + } +} + +#[test] +#[ignore = "warm text-preset listing benchmark"] +fn text_preset_listing_benchmark() { + let old = || { + let v: serde_json::Value = serde_json::from_str(include_str!("../presets/text_animators.json")).unwrap(); + let presets = v["presets"].as_array().unwrap().clone(); + presets.iter().map(|p| (p["id"].as_str().unwrap().to_string(), p["name"].as_str().unwrap().to_string())).collect::>() + }; + assert_eq!(old(), crate::text_presets()); + let mut before = Vec::new(); + let mut after = Vec::new(); + for _ in 0..5 { + let start = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(old()); + } + before.push(start.elapsed()); + let start = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(crate::text_presets()); + } + after.push(start.elapsed()); + } + before.sort(); + after.sort(); + println!("Text preset names, median per warm lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000); +} + #[test] fn menus_have_text_animation_entries() { let (s, _) = session(); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd8..9ed7e0df 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -8,6 +8,7 @@ use crate::i18n::tr; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; +use std::sync::Arc; use crate::icons::{self, Icon}; use crate::panels::DragPayload; @@ -228,7 +229,7 @@ fn preset_row( name: &str, auto_id: &str, query: &str, - on_apply: Apply, + on_apply: impl FnOnce() -> Apply, apply: &mut Option, ) { let t = app.tokens; @@ -245,22 +246,22 @@ fn preset_row( highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t); app.auto.add(auto_id, r, name); if resp.double_clicked() { - *apply = Some(on_apply); + *apply = Some(on_apply()); } } /// User preset files, rescanned every few seconds (and on Refresh List). -fn user_presets(ctx: &egui::Context, force: bool) -> Vec { +fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec) -> Arc> { let id = egui::Id::new("fx-user-presets"); let now = ctx.input(|i| i.time); if !force - && let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, std::sync::Arc>)>(id)) + && let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, Arc>)>(id)) && now - at < 5.0 { - return (*v).clone(); + return v; } - let v = presets_dir().map(|d| scan_presets(&d)).unwrap_or_default(); - ctx.data_mut(|d| d.insert_temp(id, (now, std::sync::Arc::new(v.clone())))); + let v = Arc::new(scan()); + ctx.data_mut(|d| d.insert_temp(id, (now, v.clone()))); v } @@ -389,13 +390,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { categories.push(c); } } - let files: Vec = - user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect(); + let user = user_presets(&ctx, force_scan, || presets_dir().map(|d| scan_presets(&d)).unwrap_or_default()); + let files: Vec<&PresetFile> = user.iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect(); let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty(); if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) { for (id, name) in text { - preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, || Apply::TextPreset(id.clone()), &mut apply); } } for cat in &categories { @@ -403,7 +404,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) { - preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, || Apply::Builtin(id.clone()), &mut apply); } } if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) { @@ -417,7 +418,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { &f.name, &format!("effects.preset.file.{}", f.name), &query, - Apply::PresetFile(f.path.clone()), + || Apply::PresetFile(f.path.clone()), &mut apply, ); } @@ -536,6 +537,64 @@ mod tests { let v = scan_presets(&d); assert_eq!(v.iter().map(|p| p.name.as_str()).collect::>(), vec!["bounce", "Zoom In"]); assert!(scan_presets(&d.join("missing")).is_empty()); + let ctx = egui::Context::default(); + let set_time = |time| { + let mut output = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |_| {}); + output.textures_delta.clear(); + }; + let scans = std::cell::Cell::new(0); + let scan = || { + scans.set(scans.get() + 1); + scan_presets(&d) + }; + set_time(1.0); + let first = user_presets(&ctx, false, scan); + assert_eq!(first.iter().map(|p| p.name.as_str()).collect::>(), ["bounce", "Zoom In"]); + std::fs::remove_file(d.join("bounce.ECPRESET")).unwrap(); + std::fs::write(d.join("Another.ecpreset"), "{}").unwrap(); + set_time(5.999); + let hit = user_presets(&ctx, false, scan); + assert!(Arc::ptr_eq(&first, &hit)); + assert_eq!(hit.iter().map(|p| p.name.as_str()).collect::>(), ["bounce", "Zoom In"]); + assert_eq!(scans.get(), 1); + set_time(6.0); + let expired = user_presets(&ctx, false, scan); + assert_eq!(expired.iter().map(|p| p.name.as_str()).collect::>(), ["Another", "Zoom In"]); + std::fs::remove_file(d.join("Another.ecpreset")).unwrap(); + let refreshed = user_presets(&ctx, true, scan); + assert_eq!(refreshed.iter().map(|p| p.name.as_str()).collect::>(), ["Zoom In"]); + assert_eq!(scans.get(), 3); + assert_eq!(first.iter().map(|p| p.name.as_str()).collect::>(), ["bounce", "Zoom In"]); let _ = std::fs::remove_dir_all(&d); } + + #[test] + #[ignore = "warm custom-preset cache benchmark"] + fn user_preset_cache_benchmark() { + let ctx = egui::Context::default(); + let files = user_presets(&ctx, false, || { + (0..1000).map(|i| PresetFile { name: format!("Preset {i:04}"), path: format!("/presets/Preset {i:04}.ecpreset") }).collect() + }); + assert_eq!(files.first().unwrap().name, "Preset 0000"); + assert_eq!(files.last().unwrap().name, "Preset 0999"); + let mut before = Vec::new(); + let mut after = Vec::new(); + for _ in 0..5 { + let start = std::time::Instant::now(); + for _ in 0..1000 { + let (_, shared) = ctx.data(|d| d.get_temp::<(f64, Arc>)>(egui::Id::new("fx-user-presets"))).unwrap(); + std::hint::black_box((*shared).clone()); + } + before.push(start.elapsed()); + let start = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(user_presets(&ctx, false, Vec::new)); + } + after.push(start.elapsed()); + } + assert!(Arc::ptr_eq(&files, &user_presets(&ctx, false, Vec::new))); + before.sort(); + after.sort(); + println!("1,000 custom presets, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000); + } } diff --git a/crates/ui-egui/tests/ui_presets_folder.rs b/crates/ui-egui/tests/ui_presets_folder.rs index 7ba632a6..92d72356 100644 --- a/crates/ui-egui/tests/ui_presets_folder.rs +++ b/crates/ui-egui/tests/ui_presets_folder.rs @@ -1,6 +1,7 @@ //! Effects & Presets: copy the configured folder path through the contents menu. use effectcraft_engine::Session; +use effectcraft_ui_egui::panels::effects_presets::PresetFile; use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind}; use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2}; use egui_kittest::Harness; @@ -18,8 +19,9 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> Vec { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); let pos = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); let mut copied = Vec::new(); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.step(); for pressed in [true, false] { - h.input_mut().events.push(Event::PointerMoved(pos)); h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); h.step(); for command in &h.output().platform_output.commands { @@ -88,3 +90,74 @@ fn presets_folder_control_command_returns_the_path_or_a_configuration_error() { assert_eq!(copied, [expected.as_str()]); } } + +#[test] +fn shared_preset_rows_search_apply_and_undo_the_selected_definition() { + struct Files(std::path::PathBuf); + impl Drop for Files { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let files = Files(std::env::temp_dir().join(format!( + "ec-preset-rows-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + ))); + std::fs::create_dir(&files.0).unwrap(); + let mut entries = Vec::new(); + for (name, value) in [("Custom Cache A", 35.0), ("Custom Cache B", 70.0)] { + let path = files.0.join(format!("{name}.ecpreset")); + let preset = json!({"effectcraftAnimationPreset": 1, "name": name, "properties": [{"path": "transform/opacity", "value": {"t": "Scalar", "v": value}}], "effects": []}); + std::fs::write(&path, serde_json::to_vec(&preset).unwrap()).unwrap(); + entries.push(PresetFile { name: name.into(), path: path.to_string_lossy().into_owned() }); + } + let mut session = Session::default(); + session.prefs.general.language = "en".into(); + session.execute("comp.new", json!({"width": 64, "height": 36, "duration": 2})).unwrap(); + session.execute("layer.newText", json!({"text": "Cache"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| { + let mut app = EffectcraftApp::new(session); + app.ui.start_screen = false; + app.show_panel(PanelKind::EffectsPresets); + app + }); + h.run_steps(3); + let shared = std::sync::Arc::new(entries); + let now = h.ctx.input(|i| i.time); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("fx-user-presets"), (now, shared.clone()))); + let search = |h: &mut Harness<'_, EffectcraftApp>, text: &str| { + click(h, "effects.search"); + h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(40); + }; + search(&mut h, "Typewriter"); + assert!(h.state().auto.find("effects.preset.text.typewriter").is_some()); + assert!(h.state().auto.find("effects.preset.file.Custom Cache A").is_none()); + click(&mut h, "effects.preset.text.typewriter"); + click(&mut h, "effects.preset.text.typewriter"); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().groups().count(), 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + for (name, expected) in [("Custom Cache A", 35.0), ("Custom Cache B", 70.0)] { + search(&mut h, name); + let id = format!("effects.preset.file.{name}"); + let history = h.state().session.history.undo.len(); + assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), 100.0); + click(&mut h, &id); + click(&mut h, &id); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), expected); + assert_eq!(h.state().session.history.undo.len(), history + 1); + if let Ok(dir) = std::env::var("PRESET_CACHE_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + } + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), 100.0); + } + let (_, cached) = h.ctx.data(|d| d.get_temp::<(f64, std::sync::Arc>)>(egui::Id::new("fx-user-presets"))).unwrap(); + assert!(std::sync::Arc::ptr_eq(&shared, &cached)); + assert_eq!(shared.iter().map(|p| p.name.as_str()).collect::>(), ["Custom Cache A", "Custom Cache B"]); +} From 07168cbf2242cc384e2559da43df9ed2283b982e Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:07:19 +0200 Subject: [PATCH 006/185] Settings: the page list's rows touch, with no gap between them Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/settings.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b2d2ca43..28a7c014 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -664,6 +664,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.vertical(|ui| { ui.set_width(180.0); egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { + // The rows touch, so the pointer never falls between two of them. + ui.spacing_mut().item_spacing.y = 0.0; for p in &all { let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click()); let sel = page == p.id; From bb1a0e5209c847fc0650394593d7463c5dcebedb Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 13 Oct 2026 17:42:07 +0530 Subject: [PATCH 007/185] perf(media-browser): reuse listings and cache filename sort keys --- crates/engine/src/media_browser.rs | 67 +++++++- crates/ui-egui/src/panels/media_panels.rs | 59 ++++++- .../ui-egui/tests/ui_media_browser_cache.rs | 145 ++++++++++++++++++ 3 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/ui_media_browser_cache.rs diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 5edd67ca..a77859d5 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -173,7 +173,7 @@ impl VirtualTree { }) }) .collect(); - out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + sort_entries(&mut out); Ok(out) } } @@ -193,6 +193,10 @@ pub struct Entry { pub kind: Option<&'static str>, } +fn sort_entries(entries: &mut [Entry]) { + entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); +} + const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; @@ -251,7 +255,7 @@ pub fn list(dir: &str, importable_only: bool) -> Result, String> { Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind }) }) .collect(); - out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + sort_entries(&mut out); Ok(out) } @@ -407,6 +411,10 @@ mod tests { assert_eq!(media.len(), 3); assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str())); assert!(list(&dir.join("nope").to_string_lossy(), false).is_err()); + std::fs::write(dir.join("Ω.csv"), b"x").unwrap(); + std::fs::write(dir.join("作品.csv"), b"x").unwrap(); + let all = list(&d, false).unwrap(); + assert_eq!(all.iter().map(|e| e.name.as_str()).collect::>(), ["Shots", "A.mov", "b.png", "notes.txt", "Ω.csv", "作品.csv"]); std::fs::remove_dir_all(&dir).unwrap(); } @@ -436,6 +444,61 @@ mod tests { assert_eq!(vparent("/"), None); } + #[test] + fn listings_keep_folders_first_and_stable_unicode_order() { + let mut tree = VirtualTree::default(); + for name in ["作品.csv", "ä.csv", "Ä.csv", "İ.csv", "B.csv", "a.csv", "A.csv"] { + tree.add_file(&format!("/Shoot/{name}"), &format!("/files/{name}"), 1, None); + } + for name in ["zFolder", "Alpha"] { + tree.add_dir(&format!("/Shoot/{name}")); + } + let entries = tree.list("/Shoot", true).unwrap(); + assert_eq!( + entries.iter().map(|e| e.name.as_str()).collect::>(), + ["Alpha", "zFolder", "A.csv", "a.csv", "B.csv", "İ.csv", "Ä.csv", "ä.csv", "作品.csv"] + ); + assert_eq!(entries[2].path, "/files/A.csv"); + let mut reversed = entries.iter().rev().cloned().collect::>(); + sort_entries(&mut reversed); + assert_eq!( + reversed.iter().map(|e| e.name.as_str()).collect::>(), + ["Alpha", "zFolder", "a.csv", "A.csv", "B.csv", "İ.csv", "ä.csv", "Ä.csv", "作品.csv"] + ); + } + + #[test] + #[ignore = "Media Browser filename sorting benchmark"] + fn directory_sort_benchmark() { + let entries: Vec<_> = (0..1000) + .map(|i| Entry { + name: format!("作品 {} {:04}.mov", if i % 2 == 0 { "Clip" } else { "clip" }, i * 7919 % 1000), + path: format!("/Shoot/{i}.mov"), + is_dir: i % 7 == 0, + size: 1, + modified: None, + kind: Some("video"), + }) + .collect(); + let mut before = Vec::new(); + let mut after = Vec::new(); + for _ in 0..5 { + let mut old = entries.clone(); + let start = std::time::Instant::now(); + old.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + before.push(start.elapsed()); + let mut new = entries.clone(); + let start = std::time::Instant::now(); + sort_entries(&mut new); + after.push(start.elapsed()); + assert_eq!(old, new); + std::hint::black_box(new); + } + before.sort(); + after.sort(); + println!("1,000 filename sort, median: {:?} -> {:?}", before[2], after[2]); + } + #[test] fn iso_dates() { assert_eq!(iso_date(0), "1970-01-01T00:00:00Z"); diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index 4a0ab6ae..2023fcd5 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -15,6 +15,7 @@ use effectcraft_engine::media_browser as mb; use effectcraft_engine::project::ItemId; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::{Value, json}; +use std::sync::Arc; use super::DragPayload; use super::panel_kit as kit; @@ -24,6 +25,7 @@ use crate::{EffectcraftApp, widgets}; // ---------------------------------------------------------------- Media Browser type Thumb = Option<(egui::TextureHandle, [u32; 2])>; +type Listing = (f64, Arc, String>>); /// Thumbnail of a file (cached per path; `None` while not decoded or not decodable). fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb { @@ -76,11 +78,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Listing cached per folder for a second. let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only)); let now = ctx.input(|i| i.time); - let listing: Option<(f64, Result, String>)> = ctx.data(|d| d.get_temp(lid)); + let listing: Option = ctx.data(|d| d.get_temp(lid)); let entries = match listing { Some((at, l)) if now - at < 1.0 => l, _ => { - let l = b.list(&dir, st.importable_only); + let l = Arc::new(b.list(&dir, st.importable_only)); ctx.data_mut(|d| d.insert_temp(lid, (now, l.clone()))); l } @@ -115,7 +117,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } if kit::button(app, ui, Rect::from_min_size(pos2(ax, y), vec2(w, 20.0)), &format!("mediaBrowser.action.{id}"), label, false) { kit::exec(app, "mediaBrowser.action", json!({"action": id})); - ctx.data_mut(|d| d.remove::<(f64, Result, String>)>(lid)); + ctx.data_mut(|d| d.remove::(lid)); } ax -= 6.0; } @@ -150,7 +152,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Files. let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max); - let entries = match entries { + let entries = match entries.as_ref() { Ok(e) => e, Err(e) => { p.text(list.center(), Align2::CENTER_CENTER, e, Tokens::ui(12.0), t.danger); @@ -409,3 +411,52 @@ pub fn import_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect) -> f } full.max.y - 16.0 - bottom } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "Media Browser warm listing cache benchmark"] + fn listing_cache_benchmark() { + let ctx = egui::Context::default(); + let entries: Vec<_> = (0..1000) + .map(|i| mb::Entry { + name: format!("Clip {i:04}.mov"), + path: format!("/Shoot/作品 Clip {i:04}.mov"), + is_dir: false, + size: 1, + modified: None, + kind: Some("video"), + }) + .collect(); + let old_id = egui::Id::new("old-list"); + let new_id = egui::Id::new("new-list"); + ctx.data_mut(|d| { + d.insert_temp(old_id, (0.0, Ok::<_, String>(entries.clone()))); + d.insert_temp(new_id, (0.0, Arc::new(Ok::<_, String>(entries)))); + }); + let (_, original) = ctx.data(|d| d.get_temp::(new_id)).unwrap(); + assert_eq!(original.as_ref().as_ref().unwrap()[0].name, "Clip 0000.mov"); + let mut before = Vec::new(); + let mut after = Vec::new(); + for _ in 0..5 { + let start = std::time::Instant::now(); + for _ in 0..1000 { + let _ = std::hint::black_box(ctx.data(|d| d.get_temp::<(f64, Result, String>)>(old_id)).unwrap()); + } + before.push(start.elapsed()); + let start = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(ctx.data(|d| d.get_temp::(new_id)).unwrap()); + } + after.push(start.elapsed()); + } + let (_, cached) = ctx.data(|d| d.get_temp::(new_id)).unwrap(); + assert!(Arc::ptr_eq(&original, &cached)); + assert_eq!(cached.as_ref().as_ref().unwrap().last().unwrap().name, "Clip 0999.mov"); + before.sort(); + after.sort(); + println!("1,000 Media Browser entries, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000); + } +} diff --git a/crates/ui-egui/tests/ui_media_browser_cache.rs b/crates/ui-egui/tests/ui_media_browser_cache.rs new file mode 100644 index 00000000..feade97b --- /dev/null +++ b/crates/ui-egui/tests/ui_media_browser_cache.rs @@ -0,0 +1,145 @@ +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; + +use effectcraft_engine::{ + Session, + media_browser::{Browser, Entry, VirtualTree}, +}; +use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind}; +use egui::{Event, Modifiers, PointerButton, pos2}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +#[derive(Default)] +struct MutableBrowser { + tree: Mutex, + offline: AtomicBool, +} + +impl Browser for MutableBrowser { + fn home(&self) -> String { + "/Shoot".into() + } + fn list(&self, dir: &str, only: bool) -> Result, String> { + if self.offline.load(Ordering::Relaxed) { + return Err("Folder temporarily unavailable".into()); + } + self.tree.lock().unwrap().list(dir, only) + } + fn actions(&self) -> Vec<(String, String)> { + vec![("refresh".into(), "Refresh".into())] + } + fn action(&self, id: &str) -> Result { + assert_eq!(id, "refresh"); + self.tree.lock().unwrap().add_file("/Shoot/From action.csv", "/files/action.csv", 1, None); + Ok(json!({})) + } +} + +type Listing = (f64, Arc, String>>); + +fn listing(h: &Harness<'_, EffectcraftApp>, only: bool) -> Listing { + let dir = h.state().session.state.media_browser.folder.clone().unwrap_or_else(|| "/Shoot".into()); + h.ctx.data(|d| d.get_temp(egui::Id::new(("mb-list", dir, only)))).unwrap() +} + +fn names(list: &Arc, String>>) -> Vec<&str> { + list.as_ref().as_ref().unwrap().iter().map(|e| e.name.as_str()).collect() +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + let pos = pos2(e.rect[0] + 8.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +fn at(h: &mut Harness<'_, EffectcraftApp>, time: f64) { + h.input_mut().time = Some(time); + h.step(); + h.input_mut().time = Some(time); + h.step(); + h.input_mut().time = None; +} + +#[test] +fn browser_cache_refreshes_filters_navigates_and_recovers_from_errors() { + let browser = Arc::new(MutableBrowser::default()); + { + let mut tree = browser.tree.lock().unwrap(); + tree.add_file("/Shoot/Clip.csv", "/files/clip.csv", 1, None); + tree.add_file("/Shoot/notes.txt", "/files/notes.txt", 1, None); + tree.add_file("/Shoot/Sub/Child.csv", "/files/child.csv", 1, None); + } + let mut session = Session { browser: Some(browser.clone()), ..Default::default() }; + session.prefs.general.language = "en".into(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| { + let mut app = EffectcraftApp::new(session); + app.ui.start_screen = false; + app.show_panel(PanelKind::MediaBrowser); + app.ui.maximized = Some(PanelKind::MediaBrowser); + app + }); + h.run_steps(3); + let (time, first) = listing(&h, false); + assert_eq!(names(&first), ["Sub", "Clip.csv", "notes.txt"]); + assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some()); + browser.tree.lock().unwrap().add_file("/Shoot/Added.csv", "/files/added.csv", 2, Some(5)); + at(&mut h, time + 0.999); + assert!(Arc::ptr_eq(&first, &listing(&h, false).1)); + assert!(h.state().auto.find("mediaBrowser.entry.Added.csv").is_none()); + at(&mut h, time + 1.0); + let refreshed = listing(&h, false).1; + assert_eq!(names(&refreshed), ["Sub", "Added.csv", "Clip.csv", "notes.txt"]); + assert!(h.state().auto.find("mediaBrowser.entry.Added.csv").is_some()); + assert_eq!(names(&first), ["Sub", "Clip.csv", "notes.txt"]); + click(&mut h, "mediaBrowser.importableOnly"); + assert!(h.state().session.state.media_browser.importable_only); + assert_eq!(names(&listing(&h, true).1), ["Sub", "Added.csv", "Clip.csv"]); + assert!(h.state().auto.find("mediaBrowser.entry.notes.txt").is_none()); + click(&mut h, "mediaBrowser.importableOnly"); + assert!(!h.state().session.state.media_browser.importable_only); + assert!(Arc::ptr_eq(&refreshed, &listing(&h, false).1)); + click(&mut h, "mediaBrowser.action.refresh"); + assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv", "notes.txt"]); + assert!(h.state().auto.find("mediaBrowser.entry.From action.csv").is_some()); + assert_eq!(names(&refreshed), ["Sub", "Added.csv", "Clip.csv", "notes.txt"]); + h.run_steps(40); + click(&mut h, "mediaBrowser.entry.Sub"); + click(&mut h, "mediaBrowser.entry.Sub"); + assert_eq!(h.state().session.state.media_browser.folder.as_deref(), Some("/Shoot/Sub")); + assert_eq!(names(&listing(&h, false).1), ["Child.csv"]); + assert!(h.state().auto.find("mediaBrowser.entry.Child.csv").is_some()); + browser.tree.lock().unwrap().remove("/Shoot/Sub/Child.csv"); + let time = listing(&h, false).0; + at(&mut h, time + 1.0); + assert_eq!(names(&listing(&h, false).1), Vec::<&str>::new()); + assert!(h.state().auto.find("mediaBrowser.entry.Child.csv").is_none()); + click(&mut h, "mediaBrowser.up"); + assert_eq!(h.state().session.state.media_browser.folder.as_deref(), Some("/Shoot")); + assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some()); + browser.offline.store(true, Ordering::Relaxed); + let time = listing(&h, false).0; + at(&mut h, time + 1.0); + let (time, error) = listing(&h, false); + assert_eq!(error.as_ref(), &Err("Folder temporarily unavailable".into())); + assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Folder temporarily unavailable"))); + assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_none()); + browser.offline.store(false, Ordering::Relaxed); + browser.tree.lock().unwrap().remove("/Shoot/notes.txt"); + at(&mut h, time + 0.999); + assert!(Arc::ptr_eq(&error, &listing(&h, false).1)); + at(&mut h, time + 1.0); + assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv"]); + assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some()); + if let Ok(path) = std::env::var("MEDIA_BROWSER_SNAPSHOT") { + h.render().unwrap().save(path).unwrap(); + } +} From 89ae151c03de0aea294149a6d47a561c3a3b2776 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 14:01:24 +0200 Subject: [PATCH 008/185] #1221: two path-format test failures on Windows Problem: on Windows two tests failed on main although the app is fine; they assumed macOS/Linux path formats. Fix: the CLI test strips the verbatim path prefix that canonicalize adds on Windows (a child's current_dir() never reports it); the host sink test uses a drive-letter path on Windows (a path rooted without a drive is joined to the home drive by the engine, the test expected the current drive). Test code only. Co-Authored-By: Claude Sonnet 5.5 --- apps/effectcraft-cli/tests/cli.rs | 11 +++++++++-- crates/host/src/lib.rs | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 62aefd5d..bd5d5002 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -25,12 +25,19 @@ fn ok_json(args: &[&str]) -> Value { v } +/// `std::fs::canonicalize` without Windows' `\\?\` verbatim prefix, which a child's `current_dir()` never reports. +fn canonical(path: &std::path::Path) -> std::io::Result { + let path = std::fs::canonicalize(path)?; + let plain = path.to_str().and_then(|s| s.strip_prefix(r"\\?\")).filter(|s| s.as_bytes().get(1) == Some(&b':')); + Ok(plain.map(PathBuf::from).unwrap_or(path)) +} + fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); // macOS's `/var` is a symlink to `/private/var`: a child process resolves its working // directory through `current_dir()`, which is canonical, so compare canonical paths. - std::fs::canonicalize(&d).unwrap_or(d).join(name) + canonical(&d).unwrap_or(d).join(name) } #[test] @@ -260,7 +267,7 @@ fn render_out_is_relative_to_the_working_directory() { std::fs::create_dir_all(&proj).unwrap(); std::fs::create_dir_all(&elsewhere).unwrap(); // current_dir resolves aliases such as macOS /var -> /private/var. - let root = root.canonicalize().unwrap(); + let root = canonical(&root).unwrap(); let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere")); let project = proj.join("x.ecproj"); let p = project.to_str().unwrap(); diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 1e5311b0..bdb4e2bc 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -290,7 +290,8 @@ mod tests { let mut s = super::session(); s.exporter = Some(Arc::new(super::FileExporter { sink: Some(Arc::new(move |p: &str, d: Vec| g.lock().unwrap().push((p.to_string(), d)))) })); s.execute("file.openDemoProject", json!({})).unwrap(); - let dir = "/no-such-dir-effectcraft-sink-test"; + // Rooted without a drive letter is not absolute on Windows, where the engine joins it to the home drive. + let dir = if cfg!(windows) { "C:/no-such-dir-effectcraft-sink-test" } else { "/no-such-dir-effectcraft-sink-test" }; s.execute( "renderQueue.add", json!({"format": "gif", "output": format!("{dir}/a.gif"), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2}), From dbc595b0d6cd37db886fc0b34057c8360b6c4f1a Mon Sep 17 00:00:00 2001 From: Jannik Wappler <167129173+RavoxX@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:06 +0300 Subject: [PATCH 009/185] #1220: Hide layer bounds during path editing --- crates/ui-egui/src/panels/viewer.rs | 8 +++ crates/ui-egui/tests/ui_viewer.rs | 97 +++++++++++++++++++++++++++++ docs/gaps.md | 6 +- docs/ui-parity.md | 9 ++- 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..6ec3be36 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1028,6 +1028,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + // Path editing keeps vertices and tangents visible without the layer's transform + // box competing with them (#1220). The path's own free-transform box is separate. + let editing_path = paths.iter().any(|p| { + p.layer == l.id && (app.ui.tool.is_pen() || (app.ui.tool == Tool::Selection && sel_vertices.iter().any(|v| v.layer == l.id && v.mask == p.uid))) + }); + if editing_path { + continue; + } let Some((m, q, _)) = layer_quad(&ectx, l) else { // A 3D layer reaching past the camera: the part of its box in front of it. let outline = layer_outline(&ectx, l); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1..d37180a8 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -677,6 +677,103 @@ fn fill_options_change_the_selected_shape_layer() { } } +/// #1220: path tools hide the layer transform box, while path vertices remain editable. +#[test] +fn path_editing_hides_layer_bounds_and_keeps_corner_vertices_editable() { + for mask in [false, true] { + let mut h = harness(); + let vertices = [[200.0, 100.0], [400.0, 100.0], [400.0, 260.0], [200.0, 260.0]]; + let (layer, uid, kind) = if mask { + let layer = layer_id(&h, "Plate"); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + let r = h + .state_mut() + .session + .execute("mask.new", json!({"layer": layer, "vertices": vertices, "outTangents": [[40, 0], [0, 0], [0, 0], [0, 0]], "closed": true})) + .unwrap(); + (layer, r["mask"].as_u64().unwrap(), "mask") + } else { + let r = h.state_mut().session.execute("shape.newPath", json!({"vertices": vertices, "outTangents": [[40, 0], [0, 0], [0, 0], [0, 0]], "closed": true, "fill": false, "stroke": "#ffffff", "strokeWidth": 4})).unwrap(); + (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap(), "shapePath") + }; + h.state_mut().session.state.selected_vertices.clear(); + h.run_steps(2); + let handle = format!("viewer.handle.{layer}.0"); + assert!(h.state().auto.find(&handle).is_some(), "ordinary layer selection has transform handles (mask={mask})"); + for tool in [Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather] { + h.state_mut().ui.tool = tool; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_none(), "{tool:?}: hide the layer box"); + assert!(h.state().auto.find(&format!("viewer.{kind}.{uid}.vertex.0")).is_some(), "{tool:?}: keep the path point"); + } + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + let vertex = format!("viewer.{kind}.{uid}.vertex.0"); + let from = rect(&h, &vertex).center(); + click(&mut h, from); + h.run_steps(2); // The automation registry also keeps the preceding frame's elements. + assert!(h.state().auto.find(&handle).is_none(), "selecting a vertex starts path editing (mask={mask})"); + let position = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().value.clone(); + let path = |h: &Harness<'_, EffectcraftApp>| { + h.state() + .session + .active_comp() + .unwrap() + .layer(LayerId(layer)) + .unwrap() + .props + .find_group(uid) + .unwrap() + .get("path") + .unwrap() + .value + .as_path() + .unwrap() + .clone() + }; + let before = path(&h); + drag(&mut h, from, from + vec2(15.0, 12.0)); + assert_ne!(path(&h).vertices[0], before.vertices[0], "the corner vertex can still be moved"); + assert_eq!(path(&h).vertices[1..], before.vertices[1..]); + assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().value, position); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(path(&h), before, "moving the vertex undoes in one step"); + let z = rect(&h, "viewer.comp").width() / 640.0; + let tangent = rect(&h, &vertex).center() + vec2(40.0 * z, 0.0); + drag(&mut h, tangent, tangent + vec2(0.0, 20.0)); + assert_ne!(path(&h).out_tangents[0], before.out_tangents[0], "the Bézier handle is still editable"); + assert_eq!(path(&h).vertices, before.vertices, "a tangent drag does not move the path points"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(path(&h), before, "the tangent drag undoes in one step"); + h.state_mut().ui.tool = Tool::Rotate; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "a layer transform tool restores the box"); + h.state_mut().ui.tool = Tool::Selection; + h.state_mut().session.state.selected_vertices.clear(); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "ending vertex selection restores the box"); + } +} + +#[test] +fn path_tools_keep_bounds_on_layers_without_visible_paths() { + let mut h = harness(); + let layer = layer_id(&h, "Box"); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let handle = format!("viewer.handle.{layer}.0"); + assert!(h.state().auto.find(&handle).is_some(), "a layer without a path keeps its box"); + h.state_mut().session.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [80, 0], [80, 80]], "closed": true})).unwrap(); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_none()); + h.state_mut().ui.viewer.show_masks = false; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "hidden paths do not suppress the layer controls"); +} + #[test] fn shape_pen_draws_a_closed_shape_layer() { let mut h = harness(); diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fb..0c5e3094 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -114,6 +114,9 @@ The most important missing piece: it turns every other estimate here into a meas - Run the headless snapshot and control-channel checks on Linux (X11 and Wayland) and Windows, not only macOS. Interactions that only fail with real input (docking drags, viewer pan, drag-and-drop import, inline rename) need scripted input tests. +- Path editing (#1220): selected shape and mask paths suppress the layer transform box while + their points are edited. `ui_viewer` regressions cover Pen-family tools, Selection vertex + dragging and undo, restoration of layer controls, and layers without visible paths. - Regression evidence for [#66](https://github.com/storytold/effectcraft/issues/66): scripted pointer tests in `ui_viewer` and `ui_text_edit`, checked headlessly on Linux, cover viewer clicks, marquee selection and text picking when layers are hidden or excluded by solo. They also check @@ -322,6 +325,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---| +| 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 | | 2026-10-10 | minor | Alpha gate checked: no alpha blockers | | 2026-10-12 | minor | Gap 21: percentage guides and effect colour labels added (#1095) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668d..366d8f77 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -33,6 +33,12 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | +Path editing (#1220): the Composition viewer suppresses a selected layer’s transform box while +using a Pen-family tool on a visible path, or while selecting its vertices with the Selection +tool. Path points, Bézier handles and the path’s free-transform controls remain available. +Layer transform tools and ordinary layer selection restore the box. Headless `ui_viewer` tests +cover shape paths and masks, corner-vertex dragging and undo, and layers without visible paths. + ## Estimate **≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted @@ -44,6 +50,7 @@ Effects running; most of the rest parallelises. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | patch | Layer bounds are suppressed during path-point editing (#1220); shape/mask interaction regressions | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated | | 2026-10-10 | major | First UI parity checklist | From 2720ae5fe29d721622502e34eb0504b05a46d726 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 23:40:32 +1100 Subject: [PATCH 010/185] Wave Warp: cover the Top/Bottom edge pinning paths end to end (#1181) The pinning regression only exercised All Edges (and Left Edge in the CPU unit test), so the reported 'does not work with Top/Bottom Edge' had no coverage to catch or clear it. This holds the pinned first/last row in a CPU test at Direction 90, and adds CPU/GPU parity cases for Top (4), Bottom (6), diagonal directions, and single-edge pinning on shape layers. CPU pinning verified correct on main: the guards in dst_wave_pin/wave_pin agree; the gap was that neither edge path was tested. --- crates/effects/src/distort.rs | 14 +++++++++++++- crates/gpu/src/tests_fx_distort.rs | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index a68de109..195ac898 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -556,8 +556,20 @@ mod tests { let (a, b) = (o.img.get(pad, y + pad), im.get(0, y)); assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}"); } - let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im); + let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone()); assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b))); + // #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row, + // Bottom Edge (6) the last, and the rest of the layer still moves. + for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] { + let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)]; + let o = run("ec.distort.wavewarp", &set, im.clone()); + let pad = ((o.img.width - im.width) / 2) as i64; + for x in 0..40 { + let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row)); + assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}"); + } + assert!(o.img.data.iter().zip(im.data.iter()).any(|(a, b)| differs(*a, *b)), "pin {edge_row}: layer must still distort"); + } } #[test] diff --git a/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs index e0e89fb6..19cdcd60 100644 --- a/crates/gpu/src/tests_fx_distort.rs +++ b/crates/gpu/src/tests_fx_distort.rs @@ -28,6 +28,11 @@ fn twirl_bulge_ripple_wave_warp() { effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]); + // #1181: the single-edge pinning paths, with the default vertical displacement direction. + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(25.0)), ("pinning", e(4))]); + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(19.0)), ("pinning", e(6)), ("waveType", e(1))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(4)), ("speed", n(2.0))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(6)), ("speed", n(2.0))]); } #[test] @@ -79,6 +84,9 @@ fn shape_case(id: &str, vals: &[(&str, Value)]) { fn edge_pinning_on_a_shape_layer() { shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]); + // #1181: single-edge pinning over the layer rect, not just All Edges. + shape_case("ec.distort.wavewarp", &[("pinning", e(4)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); + shape_case("ec.distort.wavewarp", &[("pinning", e(6)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); shape_case( "ec.distort.bulge", &[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))], From 48738093b571322646247cfc91921aff804fce95 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:02:12 +1100 Subject: [PATCH 011/185] Shapes: Merge Paths keeps paint that belongs to a group above it (#1174) A Merge Paths item appended to a shape layer's Contents cleared every draw collected so far, including fills and strokes inside groups above it, so the layer's shapes vanished from the render. AE's rule is per group: only paint items in the merge's own group above it are dropped. Draws now record which contents level created them; the merge drops only its own group's paint, and the group-bubbled draws keep rendering. Adds a regression test (Merge Shapes and Add modes) that renders nothing without the fix. --- crates/render/src/shapes.rs | 13 +++++++++++-- crates/render/src/shapes/tests.rs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index aa46fa50..d5e1d5b8 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -57,6 +57,10 @@ struct Draw { blend: BlendMode, /// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D). surfaces: SurfaceColors, + /// Paint item of the contents group this draw was collected in (as opposed to one bubbled up + /// from a nested group). Merge Paths drops only these paints above it (#1174); a group's own + /// fill or stroke keeps rendering inside the group. + local: bool, } /// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`] @@ -234,6 +238,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -247,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -270,6 +276,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -328,8 +335,9 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let Some(inner) = g.sub("contents") else { continue }; @@ -341,6 +349,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec 0.99 && c[1] < 0.01 && c[3] > 0.99, "mode {mode}: group rectangle must still render, got {c:?}"); + assert!((coverage(&img) - 40.0 * 40.0).abs() < 20.0, "mode {mode}: {}", coverage(&img)); + } +} + #[test] fn merge_subtract_and_intersect() { let sub = |mode: u32| { From 9ab33d975825f0ae5e2edfd5e31dd26bcb2e0122 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Tue, 13 Oct 2026 23:25:57 +0900 Subject: [PATCH 012/185] i18n(ja): the first rows of the Japanese effect catalog (#821) --- crates/ui-egui/src/i18n/effects.rs | 47 ++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 3ab11558..e49aba2c 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -439,13 +439,56 @@ const TRADITIONAL_CHINESE: &[Row] = &[]; /// `CC` series, the `VR` set, the `OCIO` set, `Cryptomatte`, `EXtractoR`, `IDentifier`, /// `Key Light`, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects) repeat that /// English name here rather than being left out. -const JAPANESE_EFFECTS: &[Row] = &[]; +/// +/// The rows below were read from the installed After Effects 2026 (26.5) Japanese interface +/// (its generated Effect menu and its Effects & Presets panel). +const JAPANESE_EFFECTS: &[Row] = &[ + ("ec.blur.bilateral", "Bilateral Blur", "ブラー(バイラテラル)"), + ("ec.blur.cameralens", "Camera Lens Blur", "ブラー(カメラレンズ)"), + ("ec.blur.channel", "Channel Blur", "ブラー(チャンネル)"), + ("ec.blur.compound", "Compound Blur", "ブラー(合成)"), + ("ec.blur.directional", "Directional Blur", "ブラー(方向)"), + ("ec.blur.fastbox", "Fast Box Blur", "高速ボックスブラー"), + ("ec.blur.gaussian", "Gaussian Blur", "ブラー(ガウス)"), + ("ec.blur.radial", "Radial Blur", "ブラー(放射状)"), + ("ec.color.lumetri", "Lumetri Color", "Lumetri カラー"), + ("ec.key.colorkey", "Color Key", "カラーキー"), + ("ec.key.luma", "Luma Key", "ルミナンスキー"), + ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), + ("ec.obsolete.basic3d", "Basic 3D", "基本 3D"), + ("ec.obsolete.basictext", "Basic Text", "基本テキスト"), + ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"), + ("ec.obsolete.lightning", "Lightning", "稲妻"), + ("ec.obsolete.pathtext", "Path Text", "パステキスト"), + ("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"), +]; /// Traditional Chinese (`zh-hant`) category labels, keyed by the registry's English category. const TRADITIONAL_CHINESE_CATEGORIES: &[(&str, &str)] = &[]; /// Japanese (`ja`) category labels, keyed by the registry's English category. -const JAPANESE_CATEGORIES: &[(&str, &str)] = &[]; +const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ + ("3D Channel", "3D チャンネル"), + ("Audio", "オーディオ"), + ("Blur & Sharpen", "ブラー&シャープ"), + ("Channel", "チャンネル"), + ("Color Correction", "カラー補正"), + ("Distort", "ディストーション"), + ("Expression Controls", "式制御"), + ("Immersive Video", "イマーシブビデオ"), + ("Keying", "キーイング"), + ("Matte", "マット"), + ("Noise & Grain", "ノイズ&グレイン"), + ("Obsolete", "旧バージョン"), + ("Paint", "描画"), + ("Perspective", "遠近"), + ("Simulation", "シミュレーション"), + ("Stylize", "スタイライズ"), + ("Text", "テキスト"), + ("Time", "時間"), + ("Transition", "トランジション"), + ("Utility", "ユーティリティ"), +]; /// The 21 category folder names the generated Effect menu and Effects & Presets draw, in /// Simplified Chinese. Read from the installed After Effects 2026 (26.5.0.89) Simplified Chinese From 1cc315708e2d50675efdd787399547c7fee510a9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 07:13:36 -0700 Subject: [PATCH 013/185] Find Missing Fonts: fonts found by their PostScript or full name are no longer listed File > Dependencies > Find Missing Fonts listed a text layer whenever its font matched no family name, while text layout finds a font by its family, PostScript or full name (effectcraft_text::resolve). For a layer set to the PostScript name Inter-Bold, EffectCraft draws the text in Inter Bold, and text.fontFeatures reports the layer's font as not missing. Find Missing Fonts listed that layer and a layer set to the full name Inter SemiBold. find_missing now lists a layer when effectcraft_text::resolve, which text layout uses for each run, reports the layer's font as missing. fonts_named_by_their_postscript_or_full_name_are_not_missing adds text layers in Noto Serif, Inter-Bold, Inter SemiBold and a font that no face has, and checks that Find Missing Fonts lists only the last one. --- crates/engine/src/commands/file_more.rs | 3 +-- crates/engine/src/tests_menu_cmds.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index e5de4de1..346f8392 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -356,13 +356,12 @@ fn find_missing(s: &mut Session, p: &Value) -> Result { Ok(json!({"effects": found})) } "fonts" => { - let families = crate::text_families(); let mut found = vec![]; for (cid, c) in s.project.comps() { for l in &c.layers { if let Some(pr) = l.props.prop("text/sourceText") && let Some(doc) = pr.value.as_text() - && !families.iter().any(|f| f.eq_ignore_ascii_case(&doc.font)) + && effectcraft_text::resolve(&doc.font, &doc.style).missing { found.push(json!({"comp": cid.0, "layer": l.id.0, "font": doc.font})); } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f..9f3cb2d6 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -706,6 +706,21 @@ fn sequence_layers() { assert_eq!(layer(&s, c).props.prop("transform/opacity").unwrap().keys.len(), 2); } +/// File ▸ Dependencies ▸ Find Missing Fonts lists a text layer only when no face matches its +/// font by family, PostScript or full name. +#[test] +fn fonts_named_by_their_postscript_or_full_name_are_not_missing() { + let mut s = comp(); + // The first three name bundled faces by family, PostScript and full name; no face has the fourth. + let fonts = ["Noto Serif", "Inter-Bold", "Inter SemiBold", "No Such Font Family 123"]; + let ids: Vec = fonts.iter().map(|f| s.execute("layer.newText", json!({"text": "Aa", "font": f})).unwrap()["layer"].as_u64().unwrap()).collect(); + let r = s.execute("file.findMissing", json!({"what": "fonts"})).unwrap(); + let listed: Vec<(u64, &str)> = r["fonts"].as_array().unwrap().iter().map(|f| (f["layer"].as_u64().unwrap(), f["font"].as_str().unwrap())).collect(); + assert_eq!(listed, [(ids[3], fonts[3])], "{r}"); + let f = s.execute("text.fontFeatures", json!({"layer": ids[1]})).unwrap(); + assert_eq!((&f["family"], &f["style"], &f["missing"]), (&json!("Inter"), &json!("Bold"), &json!(false)), "{f}"); +} + #[test] fn file_menu_project_ops() { let mut s = comp(); From 4ddf5ffd6fcee4f52a0be584dd8c851254614e68 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:01:30 +1100 Subject: [PATCH 014/185] CC Particle World: motion blur smears particles across the shutter (#1195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the comp's and the layer's motion-blur switches on, the renderer hands effects the shutter (angle, phase, samples) — CC Particle World now uses it: eight to sixty-four sprite fields sampled across the shutter are averaged into the transfer, so fast particles draw the motion trails After Effects shows instead of one sharp frozen field. The GPU particle pass defers to the CPU while the shutter is open (catalog::gpu_supported), keeping the oracle identical on both backends. Tests: sim::particle_world_motion_blur_trails (blurred differs from sharp, lights more pixels, keeps alpha energy) and a catalog assertion for the CPU gate; effects 450 and gpu 212 lib tests pass; fmt and clippy -D warnings clean. --- crates/effects/src/catalog.rs | 11 ++++++ crates/effects/src/sim.rs | 65 +++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 82678eb8..0be2b04f 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -98,6 +98,9 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { match id { "ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false, "ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false, + // #1195: CC Particle World's motion trails (the averaged shutter samples) render on the + // CPU kernel until the GPU particle pass supports them. + "ec.sim.ccparticleworld" if ctx.env.shutter.filter(|(a, _, _)| *a > 0.0).is_some() => return false, _ => {} } let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true }; @@ -199,6 +202,14 @@ mod tests { assert!(ok("ec.color.lumetri", &[])); assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))])); assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))])); + // #1195: CC Particle World's motion trails (shutter samples) render on the CPU. + { + let params = ctx_with("ec.sim.ccparticleworld", &[]); + let env = crate::EffectEnv { frame_rate: 30.0, shutter: Some((180.0, -90.0, 16)), ..Default::default() }; + let ctx = crate::EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env }; + assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx)); + } + assert!(ok("ec.sim.ccparticleworld", &[])); assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))])); assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))])); assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))])); diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 2c7b97d1..14146a9f 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -238,9 +238,22 @@ impl SpritePlan { } /// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`. - pub fn finish(&self, mut b: Buf) -> Buf { + pub fn finish(&self, b: Buf) -> Buf { let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc); - b.img = match self.post { + self.finish_with(b, fx) + } + + /// [`finish`](Self::finish) with a prepared sprite image (the motion-blurred average of + /// several frames, #1195). + pub fn finish_with(&self, b: Buf, fx: Image) -> Buf { + self.post.apply(b, fx) + } +} + +impl Post { + /// Meet the sprite image `fx` with the original layer `b` (see [`SpritePlan::finish`]). + fn apply(self, mut b: Buf, fx: Image) -> Buf { + b.img = match self { Post::Combine(m) => combine(&b.img, &fx, m), Post::Replace => fx, Post::Lerp(t) => { @@ -1069,6 +1082,29 @@ fn pw_phys(ctx: &EffectCtx) -> Phys { } fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf { + // #1195: with the comp's motion-blur shutter open, particles draw as motion trails: the + // sprite fields of samples across the shutter are averaged, as Transform averages its + // matrix samples. (The GPU compositor runs this case on the CPU: catalog::gpu_supported.) + if let Some((angle, phase, n)) = ctx.env.shutter.filter(|(a, _, _)| *a > 0.0) { + let n = (n.clamp(2, 64) as usize).max(2); + let fd = 1.0 / ctx.fps().max(0.001); + let mut acc = Image::new(b.img.width, b.img.height); + let mut post: Option = None; + for k in 0..n { + let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd; + let sub = EffectCtx { params: ctx.params, time: t, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env }; + if let Some(plan) = particle_world_plan(&sub, &b) { + let fx = splat(b.img.width, b.img.height, &plan.sprites, plan.acc); + acc.data.iter_mut().zip(&fx.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c])); + post.get_or_insert(plan.post); + } + } + acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= n as f32)); + return match post { + Some(p) => p.apply(b, acc), + None => b, + }; + } match particle_world_plan(ctx, &b) { Some(plan) => plan.finish(b), None => b, @@ -1715,6 +1751,31 @@ mod tests { ] } + /// #1195: with the comp's shutter open, the particles smear across it: the average of the + /// sampled sprite fields differs from the sharp frame, touches more pixels and keeps the + /// alpha energy. + #[test] + fn particle_world_motion_blur_trails() { + let v = pw_vals(21.0); + let blank = Image::new(40, 40); + let sharp = run("ec.sim.ccparticleworld", &v, blank.clone(), 2.0); + let blurred = crate::run_fx( + "ec.sim.ccparticleworld", + &v, + blank.clone(), + 2.0, + EffectEnv { frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() }, + ) + .img; + assert!(sharp.data.iter().any(|p| p[3] > 0.0), "sharp frame has particles"); + assert_ne!(sharp.data, blurred.data, "the shutter smears the particles"); + let lit = |img: &Image| img.data.iter().filter(|p| p[3] > 0.005).count(); + let sum = |img: &Image| img.data.iter().map(|p| p[3]).sum::(); + assert!(lit(&blurred) > lit(&sharp), "trails light more pixels: {} vs {}", lit(&blurred), lit(&sharp)); + let (sa, ba) = (sum(&sharp), sum(&blurred)); + assert!((ba - sa).abs() < sa * 0.6 + 1.0, "alpha energy roughly kept: {ba} vs {sa}"); + } + #[test] fn particle_world_seek_consistent() { let v = pw_vals(11.0); From f12d75e37aa2f8f2032b3bf9eafa9434e55b2b03 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:15:32 +1100 Subject: [PATCH 015/185] Engine: Interpret Footage loop coverage for sequences (#1068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Looped *video* footage had a regression (#1045); a grouped PNG sequence going through the same path — interpret a fresh placeholder to Loop 15, drag it onto the timeline — did not. The behaviour is already correct on main (addItem sizes a sequence layer from looped_duration, the pool's frame_index wraps per cycle), so this is coverage, not a change: interpret_footage_loop_extends_sequence_layers asserts a 15-loop of a 0.4 s sequence lands a 6 s layer. cargo test -p effectcraft-engine --lib interpret_footage: 4 passed. --- crates/engine/src/tests_menu_cmds.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f..1935f1de 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1556,6 +1556,31 @@ fn interpret_footage_loop_extends_video_layers() { assert_eq!(s.project.item(effectcraft_project::ItemId(p)).unwrap().duration(), Some(Tick::from_seconds_f64(6.0))); } +/// #1068: the same for an interpreted PNG sequence — the loop count survives interpretation and +/// a freshly dragged layer plays every cycle. +#[test] +fn interpret_footage_loop_extends_sequence_layers() { + let mut s = comp(); + let p = s.execute("file.importPlaceholder", json!({"name": "Seq", "width": 320, "height": 240, "duration": 0.4})).unwrap()["item"].as_u64().unwrap(); + let id = effectcraft_project::ItemId(p); + // Make it a sequence like the importer's grouped PNGs. + s.edit("Seq", None, |proj, _| { + if let Some(ItemKind::Footage(f)) = proj.item_mut(id).map(|i| &mut i.kind) { + f.kind = effectcraft_project::FootageKind::Sequence; + f.sequence = (0..12).map(|n| format!("/tmp/seq_{n:04}.png")).collect(); + f.sync_sequence_duration(); + } + Ok(()) + }) + .unwrap(); + s.state.project_selection = vec![id]; + s.execute("file.interpretFootage", json!({"loop": 15.0})).unwrap(); + let l = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap(); + let dur = (layer.out_point - layer.in_point).seconds(); + assert!((dur - 6.0).abs() < 0.05, "15 loops of a 0.4 s sequence: {dur}"); +} + #[test] fn percentage_guides_follow_the_comp_size() { let mut s = comp(); From bde6eb57f33aa3e14f2e69ad3d725bf09a97f543 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:20:07 +1100 Subject: [PATCH 016/185] UI: Spacebar previews the demo opened from the start screen (#1059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported flow — launch to the start screen, Open Demo Project, press Space — does not reproduce on main: the demo comp activates, the start screen closes, and playback starts. The behaviour already held (the #279 menu-focus and #204 start-screen work fixed the neighbouring bugs), so this lands as a regression test: space_previews_after_opening_the_demo_from_home walks the exact flow headlessly and asserts playing=true. cargo test -p effectcraft-ui-egui --test ui_menus: 8 passed. --- crates/ui-egui/tests/ui_menus.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index e198a619..ca4c8cab 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -225,3 +225,22 @@ fn import_dialog_offers_mxf() { assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}"); assert!(offered.iter().any(|e| e == "mov")); } + +/// #1059: opening the demo from the start screen and immediately pressing Spacebar previews +/// the demo — the start screen's keyboard focus must not outlive it. +#[test] +fn space_previews_after_opening_the_demo_from_home() { + let mut h = { + let s = Session::default(); + let mut app = EffectcraftApp::new(s); + app.ui.start_screen = true; + Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| app) + }; + h.run_steps(3); + let demo = rect(&h, "home.file.openDemoProject"); + click_at(&mut h, demo.center()); + assert!(h.state().session.active_comp().is_some(), "the demo comp is open"); + assert!(!h.state().ui.start_screen, "the start screen closed"); + key(&mut h, Key::Space); + assert!(h.state().playback.playing, "Space previews the demo (#1059)"); +} From d15c82b1632a039c6ef695b1bd89438119376994 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:46:26 +1100 Subject: [PATCH 017/185] Liquify: expose Distortion Mesh Offset in Effect Controls (#1194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Effects' Liquify shows Distortion ▸ Offset (x, y), and the renderer already applied distortionMeshOffset — sampling the mesh at layer coordinates minus the offset — but the parameter was declared hidden, so users could neither see nor edit it, and #1194 read as 'the effect does not work and the Offset option is missing'. It is now a visible 2D Point control (still defaulting to 0,0, still scriptable by its old path). Regression test: the spec exposes it as a Point, a [20,0] offset moves the displaced-region centroid right by the offset (the render differs, and the left of the shifted mesh is back to untouched pixels), docs/effects.md refreshed via UPDATE_DOCS. --- crates/effects/src/distort4.rs | 31 ++++++++++++++++++++++++++++++- docs/effects.md | 2 +- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/distort4.rs b/crates/effects/src/distort4.rs index d1d1b0c2..b724a583 100644 --- a/crates/effects/src/distort4.rs +++ b/crates/effects/src/distort4.rs @@ -589,7 +589,7 @@ pub fn specs() -> Vec { p("meshColor", "Mesh Color", col(0.5, 0.5, 0.5), ParamUi::Color), // The strokes (see `LiquifyStroke`); written by the `liquify.stroke` command. p("distortionMesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden), - p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden), + p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Point), p("distortionPercentage", "Distortion Percentage", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), p("distortionDisplayQuality", "Distortion Display Quality", Value::Enum(1), popup(&["Draft", "Smooth"])), ], @@ -672,6 +672,35 @@ mod tests { assert_eq!(zero.img.data, img.data); let again = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default()); assert_eq!(out.img.data, again.img.data); + // #1194: Distortion Mesh Offset is an exposed, editable control (AE's Distortion ▸ + // Offset) and it moves the mesh: the pattern rendered at x renders at x + offset. + let spec = crate::find("ec.distort.liquify").unwrap(); + assert!( + spec.params.iter().any(|ps| ps.id == "distortionMeshOffset" && ps.ui == effectcraft_project::ParamUi::Point), + "offset is shown in Effect Controls" + ); + let shifted = run_fx( + "ec.distort.liquify", + &[("distortionMesh", Value::Str(s.to_line())), ("distortionMeshOffset", Value::Vec2([20.0, 0.0]))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_ne!(shifted.img.data, out.img.data, "the offset changes the render"); + // The displaced region's centroid moves right by the offset (the mesh is sampled at + // layer minus offset), and the row far left of the shifted mesh is back to untouched. + let centroid = |o: &Image| -> f32 { + let (mut wsum, mut msum) = (0.0f32, 0.0f32); + for (i, (p, q)) in o.data.iter().zip(img.data.iter()).enumerate() { + let d: f32 = (0..4).map(|k| (p[k] - q[k]).abs()).sum(); + wsum += d; + msum += d * (i % 64) as f32; + } + if wsum > 1e-6 { msum / wsum } else { 0.0 } + }; + let (co, cs) = (centroid(&out.img), centroid(&shifted.img)); + assert!((cs - co - 20.0).abs() < 5.0, "centroids {co} -> {cs}"); + assert_eq!(shifted.img.get(5, 32), img.get(5, 32), "left of the shifted mesh: untouched"); // Reconstruction undoes the warp; freeze protects. let mut rec = s.clone(); rec.tool = 9; diff --git a/docs/effects.md b/docs/effects.md index 0bfc6a7b..b8214e43 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -149,7 +149,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Corner Pin | `ec.distort.cornerpin` | 4 | GPU | 32 | Implemented | | Detail-preserving Upscale | `ec.distort.upscale` | 4 | | 32 | Implemented | | Displacement Map | `ec.distort.displacementmap` | 8 | GPU | 32 | Implemented | -| Liquify | `ec.distort.liquify` | 12 | GPU | 32 | Implemented | +| Liquify | `ec.distort.liquify` | 13 | GPU | 32 | Implemented | | Magnify | `ec.distort.magnify` | 10 | GPU | 32 | Implemented | | Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented | | Mirror | `ec.distort.mirror` | 2 | GPU | 32 | Implemented | From 2ff76392196518ee63ec19ee52040a2c7d9aaf9b Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Wed, 14 Oct 2026 00:57:21 +0900 Subject: [PATCH 018/185] i18n(ja): the resolution labels agree with the panel catalog and After Effects Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4caae8dd..04197975 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2034,9 +2034,9 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("view.zoomOut", "Zoom Out", "ズームアウト"), ("", "Resolution", "解像度"), ("view.res.full", "Full", "フル画質"), - ("view.res.half", "Half", "1/2画質"), - ("view.res.third", "Third", "1/3画質"), - ("view.res.quarter", "Quarter", "1/4画質"), + ("view.res.half", "Half", "1/2 画質"), + ("view.res.third", "Third", "1/3 画質"), + ("view.res.quarter", "Quarter", "1/4 画質"), ("view.res.custom", "Custom...", "カスタム..."), ("view.displayColorManagement", "Use Display Color Management", "ディスプレイカラーマネジメントを使用"), ("", "Simulate Output", "出力をシミュレート"), From 9050b1818eeb8e1b4eea5ec0f22a233d80d6aee7 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:02:11 +0200 Subject: [PATCH 019/185] Scripting: add the missing app.preferences.saveToDisk() so imported scripts like Duik get past startup (#1263) --- crates/script/src/prelude.js | 1 + crates/script/src/tests_callable.rs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 29f2fc49..2eeca86b 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1978,6 +1978,7 @@ var app = { savePrefAsBool: function () {}, savePrefAsFloat: function () {}, reload: function () {}, + saveToDisk: function () {}, }, newProject: function () { __call("file.newProject", {}); return new Project(); }, open: function (file) { diff --git a/crates/script/src/tests_callable.rs b/crates/script/src/tests_callable.rs index 13d6f787..e8452935 100644 --- a/crates/script/src/tests_callable.rs +++ b/crates/script/src/tests_callable.rs @@ -94,3 +94,10 @@ fn calling_without_a_name_is_a_script_error() { let o = ok(&mut s, "var c = app.project.items.addComp('N', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l.name = 'Ctl'; l"); assert_eq!(o.result, json!({ "type": "[object AVLayer]", "name": "Ctl" })); } + +#[test] +fn app_preferences_save_to_disk_is_callable() { + let mut s = session(); + let o = ok(&mut s, "app.preferences.saveToDisk(); app.preferences.reload(); [typeof app.preferences.saveToDisk, app.preferences.getPrefAsLong('a', 'b')]"); + assert_eq!(o.result, json!(["function", 0])); +} From e52ce29579732af9eca6fe1128051d5965bcd1c0 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:14:42 +1000 Subject: [PATCH 020/185] Preserve CLI template inputs when output is a hard link Compare device and inode on Linux alongside existing path checks. Cover template and values aliases with input-preservation and diagnostic checks. Sort the existing footage test's expected paths for temporary-directory portability. Co-authored-by: Codex --- apps/effectcraft-cli/src/template.rs | 15 ++++++++- apps/effectcraft-cli/tests/qa_template.rs | 41 +++++++++++++++++++++-- crates/engine/src/footage_check.rs | 3 +- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs index f4da23cd..a4247f2a 100644 --- a/apps/effectcraft-cli/src/template.rs +++ b/apps/effectcraft-cli/src/template.rs @@ -36,7 +36,20 @@ pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> { { for input in ["--template", "--values"] { if let Some(path) = args.opt(input) - && std::fs::canonicalize(path).is_ok_and(|path| path == output) + && std::fs::canonicalize(path).is_ok_and(|path| { + if path == output { + return true; + } + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::MetadataExt; + // Distinct canonical paths can still name the same file through hard links. + if let (Ok(input), Ok(output)) = (std::fs::metadata(&path), std::fs::metadata(&output)) { + return input.dev() == output.dev() && input.ino() == output.ino(); + } + } + false + }) { return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}"))); } diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/qa_template.rs index ac67280e..ccab23be 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/qa_template.rs @@ -247,7 +247,7 @@ fn template_output_alias_fixture(case: &str) -> PathBuf { dir } -fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) { +fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value { let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); let values_before = std::fs::read(dir.join("values.json")).unwrap(); // Read the inputs before interpreting the subprocess status or error text. @@ -272,7 +272,8 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) { ); assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file"); assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file"); - assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input"); + assert_eq!(result.status.code(), Some(1), "an output alias must fail before rendering overwrites an input"); + serde_json::from_slice(&result.stdout).unwrap() } #[test] @@ -652,3 +653,39 @@ fn template_missing_output_does_not_replace_extracted_media() { assert!(media_preserved, "missing --out must fail before overwriting previously extracted media"); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(target_os = "linux")] +#[test] +fn template_frame_output_cannot_alias_its_template_through_a_hard_link() { + use std::os::unix::fs::MetadataExt; + + let dir = template_output_alias_fixture("hard-linked-template"); + let input = dir.join("lt.ectemplate"); + let output = dir.join("hard-linked-template.png"); + std::fs::hard_link(&input, &output).unwrap(); + let input_metadata = std::fs::metadata(&input).unwrap(); + let output_metadata = std::fs::metadata(&output).unwrap(); + assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity"); + assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path"); + let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-template.png"); + assert_eq!(reply["error"], "--out hard-linked-template.png would overwrite --template lt.ectemplate", "{reply}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(target_os = "linux")] +#[test] +fn template_frame_output_cannot_alias_its_values_through_a_hard_link() { + use std::os::unix::fs::MetadataExt; + + let dir = template_output_alias_fixture("hard-linked-values"); + let input = dir.join("values.json"); + let output = dir.join("hard-linked-values.png"); + std::fs::hard_link(&input, &output).unwrap(); + let input_metadata = std::fs::metadata(&input).unwrap(); + let output_metadata = std::fs::metadata(&output).unwrap(); + assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity"); + assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path"); + let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-values.png"); + assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index de14b06c..ca5ff3f7 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -235,7 +235,8 @@ mod tests { paths.sort(); // Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed // separators; `Path` equality compares components, so both spellings are the same file. - let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]; + let mut expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]; + expected.sort(); assert_eq!(paths.len(), expected.len(), "{paths:?}"); for (got, want) in paths.iter().zip(expected) { assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}"); From 5270eab095a04347eed080db7a50f0e63bd75027 Mon Sep 17 00:00:00 2001 From: Ian-Chege Date: Tue, 13 Oct 2026 19:15:30 +0300 Subject: [PATCH 021/185] Masks: Mask Expansion past a curve's radius grows a solid mask instead of a ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expansion was a fill plus a stroke 2·|expansion| wide, blended per pixel. Once the expansion passed the shape's curvature radius the stroke's inner edge inverted: a 20 px circle expanded by 60 left a ring with the layer showing through the middle, +600 cut only the original dot, and negative values left islands. The outline is now offset with Offset Paths' geometry (round joins) and filled. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 2 + crates/render/src/masks.rs | 20 ++---- crates/render/src/tests_mask_expansion.rs | 82 +++++++++++++++++++++++ 3 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 crates/render/src/tests_mask_expansion.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6..655009bb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2390,6 +2390,8 @@ mod tests_frame_blend; #[cfg(test)] mod tests_mask_blur; #[cfg(test)] +mod tests_mask_expansion; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 3f8d7542..bb91ef71 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -3,7 +3,7 @@ use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; -use effectcraft_path::{FillRule, StrokeStyle}; +use effectcraft_path::{FillRule, Join}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; use effectcraft_raster::{Mask, box_blur, gaussian_blur}; use rayon::prelude::*; @@ -72,21 +72,15 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, let variable = !sp.feather.is_empty(); let mut cov = if variable { effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + } else if exp.abs() > 0.01 { + // Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right + // when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a + // ring around an uncovered middle (growing) or islands (shrinking). + let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0); + effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero) } else { effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) }; - if exp.abs() > 0.01 && !variable { - let ring = effectcraft_path::stroke_coverage( - std::slice::from_ref(&path), - &StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() }, - &m, - w, - h, - ); - cov.data.par_iter_mut().zip(ring.data.par_iter()).for_each(|(c, r)| { - *c = if exp > 0.0 { (*c + r - *c * r).min(1.0) } else { (*c * (1.0 - r)).max(0.0) }; - }); - } let feather = ctx.v2(layer, g, "feather", [0.0; 2]); if feather[0] > 0.0 || feather[1] > 0.0 { let img = cov.to_image(); diff --git a/crates/render/src/tests_mask_expansion.rs b/crates/render/src/tests_mask_expansion.rs new file mode 100644 index 00000000..700c779a --- /dev/null +++ b/crates/render/src/tests_mask_expansion.rs @@ -0,0 +1,82 @@ +//! Mask Expansion grows or shrinks the mask outline by its distance, however large: past the +//! shape's curvature radius it must still cover everything within the distance of the path, +//! never leave a ring around an uncovered middle. + +use effectcraft_color::Label; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +const W: u32 = 400; +const H: u32 = 300; + +/// A white 400×300 solid with one mask of `shape` in `mode` expanded by `expansion` pixels. +fn project(shape: ShapePath, mode: MaskMode, expansion: f64) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: W, height: H, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (W, H), None); + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", shape, mode, [255, 0, 0]); + p.next_id = next; + m.get_mut("expansion").unwrap().value = Value::Scalar(expansion); + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +/// Alpha at each of `points` for a Subtract mask on a 40 px circle at the centre (the hole). +fn hole(expansion: f64, points: &[(i64, i64)]) -> Vec { + let (p, cid) = project(ShapePath::ellipse([200.0, 150.0], 40.0, 40.0), MaskMode::Subtract, expansion); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + points.iter().map(|&(x, y)| img.get(x, y)[3]).collect() +} + +#[test] +fn no_expansion_cuts_the_circle() { + let a = hole(0.0, &[(200, 150), (215, 150), (225, 150)]); + assert!(a[0] < 0.01 && a[1] < 0.01, "inside the 20 px radius is cut: {a:?}"); + assert!(a[2] > 0.99, "outside it is kept: {a:?}"); +} + +#[test] +fn expansion_past_the_radius_grows_a_solid_hole() { + // +60 on a 20 px radius: a solid hole of radius 80. The fill + stroke blend left a ring + // from 40 to 80 px with the layer showing at 21–39 px. + let a = hole(60.0, &[(200, 150), (230, 150), (200, 120), (275, 150), (285, 150)]); + assert!(a[..4].iter().all(|&v| v < 0.01), "everything within 80 px is cut: {a:?}"); + assert!(a[4] > 0.99, "beyond 80 px is kept: {a:?}"); +} + +#[test] +fn huge_expansion_cuts_the_whole_frame() { + // +600 reaches every pixel; the blend left only the 20 px circle cut. + let a = hole(600.0, &[(200, 150), (230, 150), (0, 0), (399, 299), (399, 0)]); + assert!(a.iter().all(|&v| v < 0.01), "{a:?}"); +} + +#[test] +fn negative_expansion_shrinks_then_removes_the_hole() { + let a = hole(-15.0, &[(200, 150), (210, 150)]); + assert!(a[0] < 0.01, "a 5 px hole remains: {a:?}"); + assert!(a[1] > 0.99, "shrunk away from 10 px out: {a:?}"); + let a = hole(-25.0, &[(200, 150), (190, 150)]); + assert!(a.iter().all(|&v| v > 0.99), "shrunk past its radius, nothing is cut: {a:?}"); +} + +#[test] +fn expanded_rect_add_mask_has_round_corners() { + // A 100 px square mask (Add) grown by 30: the sides move out 30 px, the corners round off. + let (p, cid) = project(ShapePath::rect([200.0, 150.0], 100.0, 100.0), MaskMode::Add, 30.0); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + assert!(img.get(275, 150)[3] > 0.99, "25 px past the right side is inside"); + assert!(img.get(285, 150)[3] < 0.01, "35 px past it is outside"); + // 25 px diagonally out from the corner (250,100) is within 30 px: inside. + assert!(img.get(267, 82)[3] > 0.99); + // The square's bounding corner (280,70) is 42 px from the corner: a miter would cover it. + assert!(img.get(277, 73)[3] < 0.01); +} From 5569aad8b5f22715ac8052708c5e05acbe48b53d Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Wed, 14 Oct 2026 01:13:22 +0900 Subject: [PATCH 022/185] i18n(ja): four labels After Effects contradicts Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/ui.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4af..0825213a 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -553,7 +553,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("3D Reference Axes", "3D参照軸"), ("Affect all layers that use this solid", "この平面を使用するすべてのレイヤーに適用"), ("Choose grid and guide options", "グリッドとガイドのオプションを選択"), - ("Colorize", "カラー化"), + ("Colorize", "色付き"), ("Custom", "カスタム"), ("Custom...", "カスタム..."), ("Fit", "フィット"), @@ -561,7 +561,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Full", "フル画質"), ("Half", "1/2 画質"), ("Only this layer uses this solid.", "この平面を使用するのはこのレイヤーだけです。"), - ("Proportional Grid", "均等グリッド"), + ("Proportional Grid", "プロポーショナルグリッド"), ("Quarter", "1/4 画質"), ("Region of Interest", "関心領域"), ("Reset Exposure", "露出をリセット"), @@ -587,7 +587,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Import Image Sequence", "画像シーケンスを読み込み"), ("Import PDF / Illustrator File", "PDF / Illustratorファイルを読み込み"), ("Import Photoshop File", "Photoshopファイルを読み込み"), - ("Interpret Unlabeled Alpha As", "ラベルなしアルファの解釈"), + ("Interpret Unlabeled Alpha As", "ラベルのないアルファを変換"), ("Join the ArtCraft Discord", "ArtCraftのDiscordに参加"), ("Mercury GPU Acceleration ({})", "Mercury GPUアクセラレーション({})"), ("Mercury Software Only", "Mercuryソフトウェアのみ"), @@ -595,7 +595,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Off (Final Quality)", "オフ(最終品質)"), ("OpenGL (compatibility)", "OpenGL(互換)"), ("Opus Bitrate: {} kbps", "Opusビットレート:{} kbps"), - ("RGB Straight", "RGB(ストレート)"), + ("RGB Straight", "RGB ストレート"), ("Red", "赤"), ("Tone map", "トーンマップ"), ("Use In-Window Menu Bar on macOS", "macOSでウィンドウ内メニューバーを使用"), From 76a3e7eefe62e6633c48480318a8b07527c47733 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 11:04:02 -0600 Subject: [PATCH 023/185] Masks: addVertex and setVertex fill in missing tangents of a loaded path (#1250) A path's vertices and its two tangent arrays deserialize independently, and nothing on load makes them agree. mask.new pads them, but a path read from a project file may carry fewer tangents than vertices. mask.addVertex checked its index against the vertices only and then inserted into both tangent arrays, and mask.setVertex indexed the tangent arrays after the same check, so either command panicked on such a path (Vec insert/index out of bounds). ShapePath::fit_tangents gives both arrays one entry per vertex (missing tangents become corners, extras are dropped), and edit_path_counted calls it on the value it edits, so every mask/shape-path vertex command works on a consistent path. Other keyframes of an animated path were already padded by apply_count_op when the vertex count changes. Tests: two regression tests save a project, cut every mask path's tangent arrays to one entry (static value and keyframes), reopen it through Project::from_json and run setVertex and addVertex. Both panic at mask.rs:209 without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/mask.rs | 3 ++ crates/engine/src/tests_timeline.rs | 60 +++++++++++++++++++++++++++++ crates/keyframe/src/value.rs | 10 +++++ 3 files changed, 73 insertions(+) diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 359de14d..8da0330a 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -132,6 +132,9 @@ fn edit_path_counted(s: &mut Session, p: &Value, label: &str, f: impl FnOnce( let roto = super::paths::is_roto(g); let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?; let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) }; + // A path read from a file may have fewer tangents than vertices; the edits below index + // all three arrays by vertex (#1250). + sp.fit_tangents(); let (r, op) = f(&mut sp)?; if roto { super::paths::roto_smooth(&mut sp); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 71a9943c..50f60014 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -331,6 +331,66 @@ fn pen_tool_draws_edits_and_closes_a_mask() { assert_eq!(s.active_comp().unwrap().layers[0].masks().unwrap().children.len(), 1); } +/// Cut every mask path's tangent arrays in the saved project to one entry (static values and +/// keyframes alike), then open the result: the shape of a hand-edited or foreign file (#1250). +fn reopen_with_short_tangents(s: &mut Session) { + fn cut(v: &mut Value) { + match v { + Value::Object(o) => { + if o.contains_key("vertices") { + for k in ["in_tangents", "out_tangents"] { + if let Some(Value::Array(a)) = o.get_mut(k) { + a.truncate(1); + } + } + } + o.values_mut().for_each(cut); + } + Value::Array(a) => a.iter_mut().for_each(cut), + _ => {} + } + } + let mut j: Value = serde_json::from_str(&s.project.to_json()).unwrap(); + cut(&mut j); + let p = effectcraft_project::Project::from_json(&j.to_string()).unwrap(); + s.replace_project(p, None); +} + +#[test] +fn vertex_commands_fill_in_missing_tangents_of_a_loaded_path() { + let (mut s, l) = setup(); + let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap(); + reopen_with_short_tangents(&mut s); + let path = |s: &Session| prop(s, l, "masks/#1/path").value.as_path().unwrap().clone(); + assert_eq!(path(&s).in_tangents.len(), 1, "the file really has short tangents"); + // Both used to index the tangent arrays by vertex and panic. + s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [0, -20], "out": [0, 20]})).unwrap(); + s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [10, 90]})).unwrap(); + let p = path(&s); + assert_eq!(p.vertices.len(), 4); + assert_eq!((p.in_tangents.len(), p.out_tangents.len()), (4, 4)); + assert_eq!((p.in_tangents[2], p.out_tangents[2]), ([0.0, -20.0], [0.0, 20.0])); + assert_eq!(p.in_tangents[1], [0.0, 0.0], "a missing tangent is a corner"); +} + +#[test] +fn vertex_commands_fill_in_missing_tangents_of_a_loaded_animated_path() { + let (mut s, l) = setup(); + let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap(); + s.execute("prop.toggleAnimation", json!({"layer": l, "path": "masks/#1/path"})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 0, "point": [0, 0]})).unwrap(); + reopen_with_short_tangents(&mut s); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [5, 5]})).unwrap(); + s.execute("mask.addVertex", json!({"layer": l, "mask": m, "index": 3, "point": [10, 90]})).unwrap(); + for k in &prop(&s, l, "masks/#1/path").keys { + let p = k.value.as_path().unwrap(); + assert_eq!(p.vertices.len(), 4, "the other key gains the vertex too"); + assert!(p.in_tangents.len() == 4 && p.out_tangents.len() == 4, "{p:?}"); + } +} + #[test] fn alt_click_stopwatch_toggles_a_self_reference_expression() { let (mut s, l) = setup(); diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index fa66711b..1dff4477 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -42,6 +42,16 @@ impl ShapePath { pub fn len(&self) -> usize { self.vertices.len() } + /// Give both tangent arrays one entry per vertex: missing tangents become zero (a corner), + /// extra ones are dropped. The three arrays are read independently from a project file, so a + /// loaded path can disagree with itself (#1250). Returns whether anything changed. + pub fn fit_tangents(&mut self) -> bool { + let n = self.vertices.len(); + let changed = self.in_tangents.len() != n || self.out_tangents.len() != n; + self.in_tangents.resize(n, [0.0; 2]); + self.out_tangents.resize(n, [0.0; 2]); + changed + } /// Number of segments (vertex to next vertex, the closing one included). pub fn segment_count(&self) -> usize { let n = self.vertices.len(); From d309d6f1d0d59362eb6213f4164ef777a3f0a510 Mon Sep 17 00:00:00 2001 From: Lloyd Nicholson Date: Tue, 13 Oct 2026 18:53:31 +0200 Subject: [PATCH 024/185] Effects & Presets: drag animation presets onto a layer Preset rows (text, built-in and user .ecpreset) were click-only, so dragging one onto a layer did nothing. They now start a DragPayload::Preset drag with a ghost, and the Timeline, Composition viewer and Effect Controls apply the preset to the layer it is dropped on, as they already do for effects and as After Effects does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 10 ++-- crates/ui-egui/src/panels/effects_presets.rs | 41 +++++++-------- crates/ui-egui/src/panels/mod.rs | 52 ++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 16 ++++-- crates/ui-egui/src/panels/viewer_drop.rs | 19 +++++-- 5 files changed, 107 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39..4d3bf833 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1394,14 +1394,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(panel_clip); let content_h = y + scroll.offset - body.min.y; scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); - // Drop effects here. + // Drop effects and animation presets here. if let Some(payload) = egui::DragAndDrop::payload::(&ctx) && ui.rect_contains_pointer(rect) - && let crate::panels::DragPayload::Effect(e) = payload.as_ref() + && let Some((id, params)) = match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)), + _ => None, + } { p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); if ctx.input(|i| i.pointer.any_released()) { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [layer.id.0]}))); + actions.push((id.into(), params)); egui::DragAndDrop::clear_payload(&ctx); } } diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd8..a9410d98 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -2,15 +2,15 @@ //! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an //! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition //! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the -//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies -//! it there. +//! selected layers; dragging an effect or an animation preset onto a layer (timeline, viewer, +//! Effect Controls) applies it there. use crate::i18n::tr; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; use crate::icons::{self, Icon}; -use crate::panels::DragPayload; +use crate::panels::{DragPayload, PresetRef}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -107,10 +107,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: & enum Apply { Effect(String), - TextPreset(String), - /// A built-in animation preset (Transitions…) by id. - Builtin(String), - PresetFile(String), + /// A text, built-in (Transitions…) or user file animation preset. + Preset(PresetRef), } /// A folder row; returns whether it is open. @@ -217,7 +215,7 @@ fn effect_row( } } -/// A preset row (no drag; double-click applies). +/// A preset row: double-click applies to the selected layers; drag it onto a layer to apply it there. #[allow(clippy::too_many_arguments)] fn preset_row( app: &mut EffectcraftApp, @@ -228,7 +226,7 @@ fn preset_row( name: &str, auto_id: &str, query: &str, - on_apply: Apply, + preset: PresetRef, apply: &mut Option, ) { let t = app.tokens; @@ -237,15 +235,18 @@ fn preset_row( if r.max.y < list.min.y || r.min.y > list.max.y { return; } - let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click()); + let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click_and_drag()); if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim); highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t); app.auto.add(auto_id, r, name); + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), DragPayload::Preset { preset: preset.clone(), name: name.to_string() }); + } if resp.double_clicked() { - *apply = Some(on_apply); + *apply = Some(Apply::Preset(preset)); } } @@ -395,7 +396,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) { for (id, name) in text { - preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, PresetRef::Text(id.clone()), &mut apply); } } for cat in &categories { @@ -403,7 +404,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) { - preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, PresetRef::Builtin(id.clone()), &mut apply); } } if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) { @@ -417,7 +418,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { &f.name, &format!("effects.preset.file.{}", f.name), &query, - Apply::PresetFile(f.path.clone()), + PresetRef::File(f.path.clone()), &mut apply, ); } @@ -455,10 +456,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let content = y + scroll.offset - list.min.y; scroll.end(ui, &mut app.auto, "effects.scroll", content, &t); - if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::(&ctx).as_deref() - && let Some(pos) = ctx.pointer_hover_pos() + if let Some(name) = egui::DragAndDrop::payload::(&ctx).and_then(|p| match p.as_ref() { + DragPayload::Effect(id) => Some(effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default()), + DragPayload::Preset { name, .. } => Some(name.clone()), + _ => None, + }) && let Some(pos) = ctx.pointer_hover_pos() { - let name = effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default(); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-fx"))); let g = painter.layout_no_wrap(name, Tokens::ui(12.0), t.text); let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0)); @@ -473,9 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let (cmd, params) = match &apply { Apply::Effect(id) => ("effect.apply", json!({"effect": id})), - Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})), - Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})), - Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})), + Apply::Preset(preset) => preset.command(), }; match app.session.execute(cmd, params) { Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c3..4718d79d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -75,12 +75,49 @@ pub enum DragPayload { Item(u64), /// An effect id from Effects & Presets. Effect(String), + /// An animation preset from Effects & Presets, with its display name for the drag ghost. + Preset { preset: PresetRef, name: String }, /// A property dragged from the timeline (to the Essential Graphics panel). Property { layer: u64, prop: u64 }, /// Files from the Media Browser (import; into the timeline: import and add). Files(Vec), } +/// An animation preset in Effects & Presets. +#[derive(Clone, Debug, PartialEq)] +pub enum PresetRef { + /// A text animation preset id (`layer.applyTextPreset`). + Text(String), + /// A built-in animation preset id, e.g. a transition (`anim.applyBuiltinPreset`). + Builtin(String), + /// A user `.ecpreset` file path (`anim.applyPreset`). + File(String), +} + +impl PresetRef { + /// The command and params that apply the preset to the selected layers. + pub fn command(&self) -> (&'static str, serde_json::Value) { + match self { + PresetRef::Text(id) => ("layer.applyTextPreset", serde_json::json!({"preset": id})), + PresetRef::Builtin(id) => ("anim.applyBuiltinPreset", serde_json::json!({"preset": id})), + PresetRef::File(path) => ("anim.applyPreset", serde_json::json!({"path": path})), + } + } + + /// The command and params that apply the preset to `layer` (a drop target). + pub fn apply_to(&self, layer: u64) -> (&'static str, serde_json::Value) { + let (id, mut params) = self.command(); + if let Some(obj) = params.as_object_mut() { + // `layer.applyTextPreset` takes one `layer`; the animation preset commands take `layers`. + match self { + PresetRef::Text(_) => obj.insert("layer".into(), serde_json::json!(layer)), + PresetRef::Builtin(_) | PresetRef::File(_) => obj.insert("layers".into(), serde_json::json!([layer])), + }; + } + (id, params) + } +} + /// Layers carried by a Project item drag. Dragging a selected item uses the /// complete selection, while dragging an unselected item uses only that item. /// Folders and data files are not composition layers. @@ -249,3 +286,18 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id)); } } + +#[cfg(test)] +mod preset_drop_tests { + use super::PresetRef; + use serde_json::json; + + #[test] + fn preset_drops_target_the_layer_with_each_commands_param() { + assert_eq!(PresetRef::Text("typewriter".into()).apply_to(7), ("layer.applyTextPreset", json!({"preset": "typewriter", "layer": 7}))); + assert_eq!(PresetRef::Builtin("fadeIn".into()).apply_to(7), ("anim.applyBuiltinPreset", json!({"preset": "fadeIn", "layers": [7]}))); + assert_eq!(PresetRef::File("/p/a.ecpreset".into()).apply_to(7), ("anim.applyPreset", json!({"path": "/p/a.ecpreset", "layers": [7]}))); + // Double-click applies to the selection: no layer given. + assert_eq!(PresetRef::Text("typewriter".into()).command(), ("layer.applyTextPreset", json!({"preset": "typewriter"}))); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..b59a5d96 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3320,7 +3320,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } - // Drop targets: effects from Effects & Presets go on the layer under the pointer. Footage and + // Drop targets: effects and animation presets from Effects & Presets go on the layer under the + // pointer. Footage and // comps from the Project panel and files from the Media Browser go where they are dropped: a // line shows the place in the stack, and over the time graph a marker shows the In point too // (Shift: at the current time). @@ -3329,11 +3330,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect)) { let released = ctx.input(|i| i.pointer.any_released()); - if let crate::panels::DragPayload::Effect(e) = payload.as_ref() { + if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) { p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); if released && let Some(l) = target { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))); + match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => { + let (id, params) = preset.apply_to(l.0); + actions.push((id.into(), params)); + } + _ => {} + } } } else { let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect(); @@ -3369,7 +3377,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } - crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Property { .. } => {} + crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {} } } } diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 2d42eb2c..44364a49 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -1,8 +1,8 @@ //! Dropping onto the Composition viewer: footage, comps and solids from the //! Project panel and files from the Media Browser become layers centred where they are dropped -//! (#85), and an effect from Effects & Presets goes on the layer under the pointer, outlined -//! while it is dragged (#88). Agents do the same with `layer.addItem {position}`, -//! `mediaBrowser.import {position}` and `effect.apply {layers}`. +//! (#85), and an effect or animation preset from Effects & Presets goes on the layer under the +//! pointer, outlined while it is dragged (#88). Agents do the same with `layer.addItem {position}`, +//! `mediaBrowser.import {position}`, `effect.apply {layers}` and the preset commands. use effectcraft_engine::project::{ItemId, ItemKind}; use effectcraft_engine::render::EvalCtx; @@ -33,6 +33,15 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain }) .into_iter() .collect(), + DragPayload::Preset { preset, .. } => layer_at(ectx, at) + .map(|l| { + if let Some((_, q, _)) = layer_quad(ectx, l) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + preset.apply_to(l.id.0) + }) + .into_iter() + .collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -45,7 +54,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], DragPayload::Property { .. } => vec![], }; - if actions.first().is_some_and(|(id, _)| *id != "effect.apply") { + // Layer drops outline the target layer; the others outline the whole viewer. + let layer_drop = matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); + if !layer_drop && !actions.is_empty() { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } if ctx.input(|i| i.pointer.any_released()) { From 36ebe2eada940f8f8fa3589fbdb41adcab4810f4 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:21:23 +1000 Subject: [PATCH 025/185] Honor duration rounding in timeToFrames Round nonzero duration frame counts away from zero while retaining absolute-time rounding, default argument selection and numeric coercion. Cover fractional durations, absolute controls, defaults and coercion. Sort expected paths in the existing footage test for temporary-directory portability. Co-authored-by: Codex --- crates/engine/src/footage_check.rs | 3 +- crates/expr/src/prelude.js | 4 +- crates/expr/src/tests.rs | 77 ++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index de14b06c..ca5ff3f7 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -235,7 +235,8 @@ mod tests { paths.sort(); // Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed // separators; `Path` equality compares components, so both spellings are the same file. - let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]; + let mut expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]; + expected.sort(); assert_eq!(paths.len(), expected.len(), "{paths:?}"); for (got, want) in paths.iter().zip(expected) { assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}"); diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 5f6dd015..e28f2cd6 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -261,7 +261,9 @@ function noise(x) { function timeToFrames(t, fps, isDuration) { if (t === undefined) t = time + thisComp.displayStartTime; if (fps === undefined) fps = 1 / __S.fd; - return Math.floor(t * fps + 1e-6); + var frames = t * fps; + if (isDuration && frames !== 0) return frames < 0 ? Math.floor(frames) : Math.ceil(frames); + return Math.floor(frames + 1e-6); } function framesToTime(frames, fps) { diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index d71db390..fd1cebbb 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1285,3 +1285,80 @@ fn layer_control_property_stands_for_its_layer() { assert_close!(f.num("effect('Map')(1).transform.scale[0]", 0.0), 100.0); assert_close!(f.num("effect('Map')(1).value.name == 'B' ? 1 : 0", 0.0), 1.0); } + +#[test] +fn time_to_frames_rounds_durations_away_from_zero() { + let f = fx(); + // Adobe's public expression reference distinguishes durations from absolute times. + // Expected integer counts are literal values, independent of the implementation. + for (expression, expected) in [ + ("timeToFrames(-0.01, 24, true)", -1.0), + ("timeToFrames(-0.05, 24, true)", -2.0), + ("timeToFrames(0, 24, true)", 0.0), + ("timeToFrames(-0.125, 24, true)", -3.0), + ("timeToFrames(0.125, 24, true)", 3.0), + ("timeToFrames(0.01, 24, true)", 1.0), + ("timeToFrames(0.05, 24, true)", 2.0), + ] { + assert_eq!(f.num(expression, 0.0), expected, "{expression}"); + } +} + +#[test] +fn time_to_frames_rounds_absolute_times_toward_negative_infinity() { + let f = fx(); + for (expression, expected) in [ + ("timeToFrames(-0.01, 24, false)", -1.0), + ("timeToFrames(-0.05, 24, false)", -2.0), + ("timeToFrames(0, 24, false)", 0.0), + ("timeToFrames(-0.125, 24, false)", -3.0), + ("timeToFrames(0.125, 24, false)", 3.0), + ("timeToFrames(0.01, 24, false)", 0.0), + ("timeToFrames(0.05, 24, false)", 1.0), + ] { + assert_eq!(f.num(expression, 0.0), expected, "{expression}"); + } +} + +#[test] +fn time_to_frames_preserves_default_arguments() { + let f = fx(); + // The fixture's composition is 30 fps with displayStartTime = 0. + for (expression, evaluation_time, expected) in [ + ("timeToFrames(0.01, 24)", 0.0, 0.0), + ("timeToFrames(-0.01, 24)", 0.0, -1.0), + ("timeToFrames(0.05)", 0.0, 1.0), + ("timeToFrames(-0.05)", 0.0, -2.0), + ("timeToFrames()", 0.0, 0.0), + ("timeToFrames()", 0.05, 1.0), + ("timeToFrames()", -0.05, -2.0), + ("timeToFrames(undefined, 24, true)", 0.05, 2.0), + ("timeToFrames(0.05, undefined, true)", 0.0, 2.0), + ] { + assert_eq!(f.num(expression, evaluation_time), expected, "{expression} at {evaluation_time}s"); + } +} + +#[test] +fn time_to_frames_preserves_coercion_and_small_durations() { + let f = fx(); + // Duration rounding must not erase tiny nonzero values; absolute times keep the existing tolerance. + for (expression, expected) in [ + ("timeToFrames('0.05', '24', false)", 1.0), + ("timeToFrames('0.05', '24', true)", 2.0), + ("timeToFrames('-0.05', '24', true)", -2.0), + ("timeToFrames(null, 24, true)", 0.0), + ("timeToFrames(0.05, null, true)", 0.0), + ("timeToFrames(0.05, 0, true)", 0.0), + ("timeToFrames(1e-10, 24, true)", 1.0), + ("timeToFrames(-1e-10, 24, true)", -1.0), + ("timeToFrames(-1e-10, 24, false)", 0.0), + ("1 / timeToFrames(-0, 24, true) === Infinity ? 1 : 0", 1.0), + ("isNaN(timeToFrames(NaN, 24, true)) ? 1 : 0", 1.0), + ("timeToFrames(Infinity, 24, true) === Infinity ? 1 : 0", 1.0), + ("timeToFrames(-Infinity, 24, true) === -Infinity ? 1 : 0", 1.0), + ("var calls = 0; var t = {valueOf: function () { calls++; return 0.05; }}; timeToFrames(t, 24, true) + 100 * calls", 102.0), + ] { + assert_eq!(f.num(expression, 0.0), expected, "{expression}"); + } +} From a15a7a4818d241ad4af2b35959af0f181bc72447 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:15:26 +0200 Subject: [PATCH 026/185] Null layers: the Video switch hides them in the Composition viewer, as in After Effects Every layer gets the Video switch (eye) in the Timeline (nulls, cameras and lights were excluded by an is_av() check). A selected layer whose eye is off no longer draws its box, handles, anchor or paths in the viewer. Alt-click on an eye and Layer > Switches > Hide Other Video now take nulls along. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 2 ++ crates/engine/src/commands/layer_menu.rs | 2 +- crates/engine/src/tests_menu_cmds.rs | 32 ++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 5 +++- crates/ui-egui/src/panels/viewer.rs | 4 ++- crates/ui-egui/tests/ui_viewer.rs | 38 ++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 7 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ca8aa865..b798493b 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -417,6 +417,8 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, let applies = |l: &Layer| match name { "audio" => l.props.sub("audio").is_some(), "lock" | "locked" => true, + // Nulls have a Video switch too (it hides their outline in the viewer). + "video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id), _ => l.source.is_av() && !mattes.contains(&l.id), }; let mut alone = true; diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 7737b8af..83a1510a 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -115,7 +115,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; s.edit("Hide Other Video", None, |proj, _| { for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() { - if !ids.contains(&l.id) && l.source.is_av() { + if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) { l.switches.video = false; } } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f..942ea64b 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -111,6 +111,38 @@ fn set_switch_others() { assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err()); } +/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles, +/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is +/// hidden (parenting ignores the eye). +#[test] +fn null_video_switch() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video"})).unwrap(); + assert!(!layer(&s, n).switches.video); + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer(&s, n).switches.video); + // Alt-click on the solid's eye hides the null too; on the null's, the solid. + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video); + s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video", "others": true})).unwrap(); + assert!(!layer(&s, a).switches.video && layer(&s, n).switches.video); + s.execute("layer.showAllVideo", json!({})).unwrap(); + s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap(); + assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video); + // The hidden null still carries its child. + s.execute("layer.setParent", json!({"layers": [a], "parent": n})).unwrap(); + s.execute("prop.set", json!({"layer": n, "path": "transform/position", "value": [420, 180, 0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let c = s.project.comp(cid).unwrap().clone(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &c, Tick::ZERO); + let child = c.layer(effectcraft_project::LayerId(a)).unwrap(); + let (m, _) = ctx.layer_to_comp(child); + let centre = m.apply(effectcraft_geom::vec2(100.0, 50.0)); + assert!((centre.x - 420.0).abs() < 1e-6 && (centre.y - 180.0).abs() < 1e-6, "{centre:?}"); +} + #[test] fn layer_switches_menu() { let mut s = comp(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..b42f06c5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1992,8 +1992,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg)); // A/V features. let sw = &layer.switches; + // Every layer has the Video switch (the eye), nulls, cameras and lights included, + // as in After Effects: it hides a null's outline in the viewer and turns a camera or + // light off. let av_items: [(Icon, bool, &str, bool); 4] = [ - (Icon::Eye, sw.video, "video", layer.source.is_av()), + (Icon::Eye, sw.video, "video", true), (Icon::Speaker, sw.audio, "audio", layer.props.sub("audio").is_some()), (Icon::Solo, sw.solo, "solo", layer.source.is_av()), (Icon::Lock, sw.locked, "lock", true), diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..f6d9bcbb 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -996,7 +996,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Layer Handles and Paths. let hs = app.session.prefs.general.path_point_size as f32 + 2.0; let label_handles = app.session.prefs.appearance.use_label_color_for_handles; - for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) { + // A layer whose Video switch is off has no box, handles or paths in the viewer (a hidden + // null disappears from the comp, as in After Effects), even while it is selected. + for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && l.switches.video) { let col = if label_handles { t.label(l.label) } else { t.accent }; // Motion path of animated position (keyframes and spatial tangents are draggable). ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1..e6097188 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -2270,3 +2270,41 @@ fn mask_marquee_selects_vertices() { let sel = &h.state().session.state.selected_vertices; assert_eq!(sel.len(), 2, "shift-marquee adds: {sel:?}"); } + +/// A null's eye in the Timeline hides it in the Composition viewer, as in After Effects: its box +/// and handles go even while it is selected and a click on its box doesn't pick it; the eye back +/// on brings them back. +#[test] +fn null_eye_hides_it_in_the_viewer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Nulls", "width": 640, "height": 360, "duration": 4})).unwrap(); + let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [n]})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(3); + let handle = format!("viewer.handle.{n}.0"); + let eye = format!("timeline.layer.{n}.video"); + assert!(h.state().auto.find(&handle).is_some(), "a shown null has handles"); + // The eye is there for a null and turns its video off. + let at = rect(&h, &eye).center(); + click(&mut h, at); + assert!(!h.state().session.active_comp().unwrap().layer(LayerId(n)).unwrap().switches.video); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_none(), "a hidden null has no handles"); + // A click on its box doesn't pick it. + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(40); // A new click, not a double-click. + let centre = screen(&h, [320.0, 180.0]); + click(&mut h, centre); + assert!(h.state().session.state.selected_layers.is_empty()); + // The eye back on: selectable again, with its handles. + h.run_steps(40); + let at = rect(&h, &eye).center(); + click(&mut h, at); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(n)).unwrap().switches.video); + h.run_steps(40); + click(&mut h, centre); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(n)]); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "handles back with the eye on"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668d..81c34085 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. |---|---|---:|---| | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed | -| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there) | | Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed | From e30c09365459e08b525c639e828895d05ca5b6e9 Mon Sep 17 00:00:00 2001 From: Moein Saharkhiz <142007707+moecr7@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:13:54 -0400 Subject: [PATCH 027/185] #1303: reject corrupt and incomplete benchmark evidence --- docs/testing.md | 6 ++ xtask/src/bench.rs | 84 +++++++++++++++++++++++++-- xtask/tests/bench.rs | 134 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 xtask/tests/bench.rs diff --git a/docs/testing.md b/docs/testing.md index fca9762e..9017e0c2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -70,3 +70,9 @@ date, load average, scenario, settings, metrics); `--compare OLD.jsonl` prints t the latest matching runs there, `--compare OLD --with NEW` compares two files without rendering. The table notes the 1-minute load average: on a busy machine wall time is noise, so compare peak memory and instructions retired, and rerun when the load differs a lot. + +Results files allow blank lines; corrupt records or invalid comparison fields report the file +and line instead of being discarded. `--fail-over PCT` requires the same run keys on both sides, +successful latest runs, and at least one comparable resource metric per run. Failed latest runs, +missing coverage, empty results and wall-time-only measurements cannot pass the gate. Omit +`--fail-over` for an exploratory partial comparison; failed latest runs remain visible. diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs index ef7a470a..fdfc3443 100644 --- a/xtask/src/bench.rs +++ b/xtask/src/bench.rs @@ -32,7 +32,8 @@ Run it under `nice -n 10` on a busy machine; the table notes the load average of --with NEW.jsonl with --compare: compare two result files, without rendering --fail-over PCT with --compare: exit 1 when a matched run got more than PCT % worse in CPU time, peak memory, max RSS or instructions retired (wall time is - left out: it follows the machine's load) + left out: it follows the machine's load). Requires matching successful + latest runs and comparable resource measurements for every key Scenarios: demo (the built-in demo comp), layers (1080p, 100 solid/text/shape layers with Gaussian Blur, Glow, Levels, Curves, Drop Shadow and keyframes), precomps (static and animated precomps @@ -744,7 +745,49 @@ fn append_line(path: &Path, v: &Value) -> Result<(), String> { fn read_jsonl(path: &Path) -> Result, String> { let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; - Ok(text.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect()) + text.lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(index, line)| { + let context = |error| format!("{}:{}: {error}", path.display(), index.saturating_add(1)); + let row: Value = serde_json::from_str(line).map_err(|error| context(format!("invalid JSON: {error}")))?; + validate_row(&row).map_err(context)?; + Ok(row) + }) + .collect() +} + +/// Validate comparison keys and measurements; unavailable platform metrics may be null/absent. +fn validate_row(row: &Value) -> Result<(), String> { + for (field, value) in [ + ("scenario", &row["scenario"]), + ("run", &row["run"]), + ("settings.resolution", &row["settings"]["resolution"]), + ("settings.format", &row["settings"]["format"]), + ] { + if value.as_str().is_none_or(|text| text.trim().is_empty()) { + return Err(format!("`{field}` must be a nonempty string")); + } + } + if row["settings"]["frames"].as_u64().is_none() { + return Err("`settings.frames` must be a nonnegative integer".into()); + } + if row["ok"].as_bool().is_none() { + return Err("`ok` must be a boolean".into()); + } + if !row["metrics"].is_object() { + return Err("`metrics` must be an object".into()); + } + if row["metrics"]["wall_s"].is_null() { + return Err("`metrics.wall_s` is required".into()); + } + for (field, _) in COMPARED { + let value = &row["metrics"][*field]; + if !value.is_null() && value.as_f64().is_none_or(|number| !number.is_finite() || number < 0.0) { + return Err(format!("`metrics.{field}` must be a finite nonnegative number or null")); + } + } + Ok(()) } // ---------------------------------------------------------------------------------------- output @@ -816,10 +859,10 @@ fn key_of(v: &Value) -> String { ) } -/// The last successful result per key (a results file accumulates runs; the latest one counts). +/// The latest result per key, including failures that supersede older successful runs. fn latest_per_key(rows: &[Value]) -> BTreeMap { let mut map = BTreeMap::new(); - for r in rows.iter().filter(|r| r["ok"] == true) { + for r in rows { map.insert(key_of(r), r.clone()); } map @@ -857,6 +900,31 @@ fn regressions(old: &BTreeMap, new: &BTreeMap, lim fn check_regressions(old: &BTreeMap, new: &BTreeMap, limit: Option) -> Result<(), String> { let Some(limit) = limit else { return Ok(()) }; + if old.is_empty() || new.is_empty() { + return Err("insufficient benchmark evidence: baseline and candidate must contain runs".into()); + } + let mut missing = vec![]; + for (key, baseline) in old { + match new.get(key) { + None => missing.push(format!("missing candidate run: {key}")), + Some(candidate) => { + for (label, row) in [("baseline", baseline), ("candidate", candidate)] { + if row["ok"] != true { + missing.push(format!("failed latest {label} run: {key}")); + } + } + if !GATED.iter().any(|field| pct(baseline["metrics"][*field].as_f64(), candidate["metrics"][*field].as_f64()).is_some()) { + missing.push(format!("no comparable resource measurements: {key}")); + } + } + } + } + for key in new.keys().filter(|key| !old.contains_key(*key)) { + missing.push(format!("missing baseline run: {key}")); + } + if !missing.is_empty() { + return Err(format!("insufficient benchmark evidence:\n {}", missing.join("\n "))); + } let bad = regressions(old, new, limit); if bad.is_empty() { println!("no run got more than {limit}% worse"); @@ -883,6 +951,10 @@ fn compare_table(old: &BTreeMap, new: &BTreeMap) - n["run"].as_str().unwrap_or(""), n["settings"]["frames"].as_u64().unwrap_or(0) )); + if o["ok"] != true || n["ok"] != true { + s.push_str(&format!(" (failed latest {} run)\n", if n["ok"] != true { "candidate" } else { "baseline" })); + continue; + } for (field, _) in COMPARED { s.push_str(&format!(" {:>8}", fmt_pct(pct(o["metrics"][field].as_f64(), n["metrics"][field].as_f64())))); } @@ -963,11 +1035,11 @@ mod tests { assert_eq!(fmt_pct(Some(25.0)), "+25.0%"); assert_eq!(fmt_pct(None), "-"); - // The latest successful run per key is the baseline; failed runs don't count. + // A successful run supersedes an earlier failure for the same key. let old = latest_per_key(&[ + row("layers", "full", 1.0, None, false), row("layers", "full", 20.0, Some(1000), true), row("layers", "full", 10.0, Some(2000), true), - row("layers", "full", 1.0, None, false), ]); assert_eq!(old.len(), 1); let new = latest_per_key(&[row("layers", "full", 8.0, Some(1500), true), row("demo", "half", 1.0, None, true)]); diff --git a/xtask/tests/bench.rs b/xtask/tests/bench.rs new file mode 100644 index 00000000..9f4a1b2f --- /dev/null +++ b/xtask/tests/bench.rs @@ -0,0 +1,134 @@ +//! Regression gates must fail when benchmark evidence is corrupt or incomplete. +use serde_json::{Value, json}; +use std::path::PathBuf; +use std::process::{Command, Output}; + +struct ResultsDir(PathBuf); +impl ResultsDir { + fn new() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!("effectcraft-bench-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + fn compare(&self, old: &str, new: &str, gate: bool) -> Output { + let baseline = self.0.join("baseline.jsonl"); + let candidate = self.0.join("candidate.jsonl"); + std::fs::write(&baseline, old).unwrap(); + std::fs::write(&candidate, new).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_xtask")); + command.args(["bench", "--compare"]).arg(baseline).arg("--with").arg(candidate); + if gate { + command.args(["--fail-over", "5"]); + } + command.output().unwrap() + } +} +impl Drop for ResultsDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn row(scenario: &str, ok: bool) -> Value { + json!({"scenario": scenario, "run": "full", "ok": ok, + "settings": {"resolution": "full", "frames": 60, "format": "png"}, + "metrics": {"wall_s": 1.0, "cpu_s": 2.0, "peak_footprint_bytes": 1000, "max_rss_bytes": null, "instructions": null}}) +} +fn diagnostic(output: &Output) -> String { + format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)) +} + +#[test] +fn corrupt_records_report_the_input_file_and_line() { + let files = ResultsDir::new(); + let valid = row("demo", true); + let mut missing = valid.clone(); + missing["settings"].as_object_mut().unwrap().remove("frames"); + let mut missing_wall = valid.clone(); + missing_wall["metrics"].as_object_mut().unwrap().remove("wall_s"); + let mut bad_metric = valid.clone(); + bad_metric["metrics"]["cpu_s"] = json!("not a measurement"); + let mut negative_metric = valid.clone(); + negative_metric["metrics"]["cpu_s"] = json!(-1); + for bad in [ + "{".into(), + "null".into(), + "[]".into(), + "{}".into(), + missing.to_string(), + missing_wall.to_string(), + bad_metric.to_string(), + negative_metric.to_string(), + ] { + let output = files.compare(&format!("{valid}\n"), &format!("{valid}\n\n \n{bad}\n"), true); + let message = diagnostic(&output); + assert!(!output.status.success(), "corrupt input passed: {bad}\n{message}"); + assert!(message.contains("candidate.jsonl:4:"), "{message}"); + } + let output = files.compare("{\n", &format!("{valid}\n"), false); + assert!(!output.status.success()); + assert!(diagnostic(&output).contains("baseline.jsonl:1:")); +} + +#[test] +fn gates_reject_failed_latest_runs_and_missing_coverage() { + let files = ResultsDir::new(); + let good = row("demo", true); + let failed = row("demo", false); + let other = row("layers", true); + for (old, new) in [ + (good.to_string(), String::new()), + (String::new(), String::new()), + (good.to_string(), failed.to_string()), + (good.to_string(), format!("{good}\n{failed}")), + (format!("{good}\n{failed}"), good.to_string()), + (good.to_string(), other.to_string()), + (format!("{good}\n{other}"), good.to_string()), + (good.to_string(), format!("{good}\n{other}")), + ] { + let output = files.compare(&old, &new, true); + assert!(!output.status.success(), "incomplete gate passed: {old} -> {new}\n{}", diagnostic(&output)); + } +} + +#[test] +fn gates_require_a_comparable_resource_metric_for_every_run() { + let files = ResultsDir::new(); + let good = row("demo", true); + let mut wall_only = good.clone(); + wall_only["metrics"]["cpu_s"] = Value::Null; + wall_only["metrics"]["peak_footprint_bytes"] = Value::Null; + let output = files.compare(&good.to_string(), &wall_only.to_string(), true); + assert!(!output.status.success(), "{}", diagnostic(&output)); + assert!(diagnostic(&output).contains("comparable")); + let mut zero = good.clone(); + zero["metrics"]["cpu_s"] = json!(0); + zero["metrics"]["peak_footprint_bytes"] = json!(0); + assert!(!files.compare(&zero.to_string(), &good.to_string(), true).status.success()); + let mut mixed = row("layers", true); + mixed["metrics"] = wall_only["metrics"].clone(); + assert!(!files.compare(&format!("{good}\n{mixed}"), &format!("{good}\n{mixed}"), true).status.success()); +} + +#[test] +fn valid_results_keep_comparison_and_exploratory_mode() { + let files = ResultsDir::new(); + let good = row("demo", true); + let failed = row("demo", false); + let output = files.compare(&format!("\n{failed}\n{good}\n"), &format!("{good}\n \n"), true); + assert!(output.status.success(), "{}", diagnostic(&output)); + assert!(diagnostic(&output).contains("+0.0%")); + let mut open = good.clone(); + open["run"] = json!("open"); + open["settings"]["resolution"] = json!("-"); + open["settings"]["frames"] = json!(0); + assert!(files.compare(&open.to_string(), &open.to_string(), true).status.success(), "project-open runs have zero rendered frames"); + let mut regression = good.clone(); + regression["metrics"]["cpu_s"] = json!(3); + assert!(!files.compare(&good.to_string(), ®ression.to_string(), true).status.success()); + let output = files.compare(&good.to_string(), &format!("{good}\n{failed}"), false); + assert!(output.status.success(), "exploratory comparison stays available"); + assert!(diagnostic(&output).contains("failed"), "latest failure is visible: {}", diagnostic(&output)); +} From 3a953859f227be26b85d6cb9c1f9d127fd9da6fc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:49 -0400 Subject: [PATCH 028/185] Ease Presets: ease the incoming, outgoing or both sides of selected keyframes (#1329) The panel eased only pairs of neighbouring selected keyframes, so a single selected key (or keys that aren't neighbours) changed nothing. An Ease selector (In, Out, In-Out; In-Out by default) now chooses the sides of each selected key to ease. In eases a key's incoming side with the curve's in side, Out its outgoing side with the curve's out side. A side is eased when it has a neighbouring key, selected or not; the neighbour is left as it is. Each side's speeds scale to the segment on that side, so a key between a +100/s and a -40/s segment gets each its own. The side not eased keeps playing as it did (easeSide, unchanged). Sides are measured before any key changes, and no undo step opens when no selected key has a segment on the chosen side; the status says which neighbour is missing. A selected pair in In-Out still gets the whole curve; its outer sides now ease too when they face an unselected key. From Keys still reads the curve of a selected pair. --- crates/script/src/tests_ease_presets.rs | 5 +- extensions/scriptui-panels/Ease Presets.jsx | 83 ++++++++++++++------- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index c803c27a..1006d1bf 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -180,7 +180,8 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension")); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Ease In-Out")); - assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}"); + // In-Out (the default) on every key: each pair gets the whole curve, as before. + assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}"); assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset")); assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset"); @@ -203,7 +204,7 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { s.execute("edit.deselectAll", json!({})).unwrap(); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Linear")); - assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property"); + assert_eq!(status(&mut s, win), "Select keyframes with another keyframe next to it"); assert_eq!(s.history.undo.len(), steps); } diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index a08fdbcc..0dd3f43a 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -1,4 +1,4 @@ -// Ease Presets: easing curves kept by name and applied to pairs of neighbouring selected keyframes. +// Ease Presets: easing curves kept by name and applied to the sides of selected keyframes. // // A ScriptUI panel that ships with EffectCraft as an optional extension (Window ▸ Ease // Presets.jsx). It uses only After Effects' documented scripting API: comp.selectedProperties, @@ -12,6 +12,11 @@ // speeds are scaled to it: per dimension, along the motion path for spatial properties, or as // overall progress for shapes. // +// Applied to selected keyframes, In eases each key's incoming side with the curve's in side, Out +// its outgoing side with the curve's out side, In-Out both: a side is eased when it has a +// neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the +// segment on that side. So a selected pair gets the whole curve, and a single key works too. +// // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", // "inSpeed"}}]}. @@ -98,9 +103,8 @@ // ------------------------------------------------------------------ keyframes - // Pairs of neighbouring selected keyframes of the active comp: {prop, key} (the earlier key's - // index), by property. - function selectedPairs() { + // Selected properties of the active comp whose keys can ease: {prop, keys} (their selected keys). + function selectedKeyed() { var comp = app.project.activeItem; var out = []; if (!(comp instanceof CompItem)) return out; @@ -109,7 +113,17 @@ var p = props[i]; if (p.propertyType !== PropertyType.PROPERTY || p.numKeys < 2 || !p.isInterpolationTypeValid(BEZIER)) continue; var keys = p.selectedKeys; - for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: p, key: keys[k] }); + if (keys.length) out.push({ prop: p, keys: keys }); + } + return out; + } + + // Pairs of neighbouring selected keyframes: {prop, key} (the earlier key's index), by property. + function selectedPairs() { + var keyed = selectedKeyed(), out = []; + for (var i = 0; i < keyed.length; i++) { + var keys = keyed[i].keys; + for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: keyed[i].prop, key: keys[k] }); } return out; } @@ -175,29 +189,39 @@ } } - // Ease every pair of neighbouring selected keyframes with `c` (one undo step). Returns the - // number of pairs eased. - function applyCurve(c) { - var pairs = selectedPairs(), n = 0; - if (!pairs.length) return 0; + // The eases of one side with `speed` (relative to the segment's average) and `influence`. + function eases(s, speed, influence) { + var out = []; + for (var d = 0; d < s.length; d++) out.push(new KeyframeEase(speed * s[d], influence)); + return out; + } + + // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in + // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns the + // number of keyframes eased. + function applyCurve(c, mode) { + var keyed = selectedKeyed(), sides = [], keys = 0; + var doIn = mode !== "Out", doOut = mode !== "In"; + // Every side and its eases first: the segments are measured before any key changes. + for (var i = 0; i < keyed.length; i++) { + var p = keyed[i].prop; + for (var j = 0; j < keyed[i].keys.length; j++) { + var k = keyed[i].keys[j], before = sides.length; + var s = doIn && k > 1 ? slopes(p, k - 1) : null; + if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + s = doOut && k < p.numKeys ? slopes(p, k) : null; + if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (sides.length > before) keys++; + } + } + if (!sides.length) return 0; app.beginUndoGroup("Apply Ease Preset"); try { - for (var i = 0; i < pairs.length; i++) { - var p = pairs[i].prop, a = pairs[i].key, s = slopes(p, a); - if (!s) continue; - var outE = [], inE = []; - for (var d = 0; d < s.length; d++) { - outE.push(new KeyframeEase(c.outSpeed * s[d], c.outInfluence)); - inE.push(new KeyframeEase(c.inSpeed * s[d], c.inInfluence)); - } - easeSide(p, a, true, outE); - easeSide(p, a + 1, false, inE); - n++; - } + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); } finally { app.endUndoGroup(); } - return n; + return keys; } // The curve between the first selected pair of keyframes (measured on the dimension that @@ -288,6 +312,13 @@ var inInfluence = field(inRow, "In %", "inInfluence"); var inSpeed = field(inRow, "Speed", "inSpeed"); + var modeRow = ui.add("group"); + modeRow.add("statictext", undefined, "Ease"); + var MODES = ["In", "Out", "In-Out"]; + var mode = modeRow.add("dropdownlist", undefined, MODES, { name: "mode" }); + mode.selection = 2; + mode.helpTip = "The sides of each selected keyframe to ease: incoming, outgoing or both"; + var actions = ui.add("group"); var applyButton = actions.add("button", undefined, "Apply", { name: "apply" }); var fromKeys = actions.add("button", undefined, "From Keys", { name: "fromKeys" }); @@ -346,8 +377,10 @@ } function apply(c, what) { - var n = applyCurve(c); - status.text = n > 0 ? "Eased " + n + " keyframe pair(s) with " + what : "Select two or more neighbouring keyframes of a property"; + var m = mode.selection ? mode.selection.text : "In-Out"; + var n = applyCurve(c, m); + var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; + status.text = n > 0 ? "Eased " + n + " keyframe(s) (" + m + ") with " + what : "Select keyframes with another keyframe " + side; return n; } From af486dc44a86dd639ca65f1d465061f50add6ebb Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:50 -0400 Subject: [PATCH 029/185] Ease Presets: tests for easing single keyframes In, Out and In-Out (#1329) One middle key between unequal segments in each mode, with its neighbours, times, values and selection unchanged and one undo restoring it; first and last keys, keys that aren't neighbours and keys of several properties; no undo step when nothing can ease; a Hold and an auto-Bezier side kept as they play; per-dimension and motion-path speeds on Scale and Position; and the pair's outer sides under In-Out and Out. All but the unchanged API tests fail on main. --- crates/script/src/tests_ease_presets.rs | 194 ++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index 1006d1bf..4d05d7c6 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -208,6 +208,200 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { assert_eq!(s.history.undo.len(), steps); } +/// Select only the keys at `keys` (property path, time in seconds). +fn select_keys(s: &mut Session, l: u64, keys: &[(&str, f64)]) { + s.execute("edit.deselectAll", json!({})).unwrap(); + let keys: Vec = keys.iter().map(|(path, t)| json!({"layer": l, "path": path, "time": t})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); +} + +/// Type a curve (Out %, Out speed, In %, In speed), choose the sides to ease and click Apply. +fn apply_typed(s: &mut Session, win: u64, mode: &str, c: [f64; 4]) { + for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) { + set(s, win, name, json!(v.to_string())); + } + set(s, win, "mode", json!(mode)); + click(s, win, "apply"); +} + +fn eased(e: Ease, speed: f64, influence: f64) -> bool { + close(e.speed, speed, 1e-9) && close(e.influence, influence, 1e-9) +} + +/// #1329: one selected keyframe eases its incoming side (In), its outgoing side (Out) or both, +/// each scaled to the segment on that side (+100/s in, −40/s out here), without changing its +/// neighbours, key times, values or the selection; one undo step restores it. +#[test] +fn one_selected_keyframe_eases_its_in_out_or_both_sides() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + // In-Out is the default. + assert_eq!(control(&mut s, win, "mode")["selection"], json!([2])); + let before = prop(&s, l, "transform/opacity"); + for mode in ["In", "Out", "In-Out"] { + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + let selected = s.state.selected_keys.clone(); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, [30.0, 0.5, 60.0, 2.0]); + assert_eq!(status(&mut s, win), format!("Eased 1 keyframe(s) ({mode}) with the curve")); + assert_eq!(s.history.undo.len(), steps + 1, "{mode}: one undo step"); + assert_eq!(s.state.selected_keys, selected, "{mode}: the selection stays"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((&op.keys[0], &op.keys[2]), (&before.keys[0], &before.keys[2]), "{mode}: the neighbours don't change"); + assert_eq!((op.keys[1].time, op.keys[1].value.as_f64()), (before.keys[1].time, 100.0), "{mode}"); + let k = &op.keys[1]; + let (ins, outs) = (mode != "Out", mode != "In"); + assert_eq!(k.in_interp, if ins { Interp::Bezier } else { Interp::Linear }, "{mode}"); + assert_eq!(k.out_interp, if outs { Interp::Bezier } else { Interp::Linear }, "{mode}"); + if ins { + assert!(eased(k.in_ease[0], 2.0 * 100.0, 0.6), "{mode}: {:?}", k.in_ease); + } else { + assert!(close(op.value_at(at(0.5)).as_f64(), 50.0, 1e-6), "{mode}: the in side still plays linearly"); + } + if outs { + assert!(eased(k.out_ease[0], 0.5 * -40.0, 0.3), "{mode}: {:?}", k.out_ease); + } else { + assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6), "{mode}: the out side still plays linearly"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys, "{mode}: undone"); + } +} + +/// A side without a neighbouring key isn't eased; when no selected key has one, nothing changes +/// (no undo step) and the panel says why. Keys that aren't neighbours, and keys of several +/// properties, each ease on their own. +#[test] +fn first_last_and_scattered_keyframes_ease_the_sides_that_have_a_segment() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + for (mode, t, why) in [("In", 0.0, "before it"), ("Out", 3.0, "after it")] { + select_keys(&mut s, l, &[("transform/opacity", t)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, c); + assert_eq!(status(&mut s, win), format!("Select keyframes with another keyframe {why}")); + assert_eq!(s.history.undo.len(), steps, "{mode}: no undo step"); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + } + // In-Out on the first key eases only its out side. + select_keys(&mut s, l, &[("transform/opacity", 0.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[0].in_interp, op.keys[0].out_interp), (Interp::Linear, Interp::Bezier)); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert_eq!(op.keys[1..], before.keys[1..]); + s.execute("edit.undo", json!({})).unwrap(); + // The first and last Opacity keys (not neighbours) and the last Rotation key. + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 3.0), ("transform/rotation", 2.0)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, "In-Out", c); + assert_eq!(status(&mut s, win), "Eased 3 keyframe(s) (In-Out) with the curve"); + assert_eq!(s.history.undo.len(), steps + 1); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert!(eased(op.keys[2].in_ease[0], 2.0 * -40.0, 0.6), "{:?}", op.keys[2].in_ease); + assert_eq!(op.keys[1], before.keys[1], "the unselected middle key"); + let rot = prop(&s, l, "transform/rotation"); + assert!(eased(rot.keys[1].in_ease[0], 2.0 * 45.0, 0.6), "{:?}", rot.keys[1].in_ease); + assert_eq!(rot.keys[0].out_interp, Interp::Linear, "the unselected neighbour"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + assert!(prop(&s, l, "transform/rotation").keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear)); +} + +/// A change from the pair-only panel: In-Out on a selected pair also eases the pair's outer sides +/// that face an unselected key (the selected keys' own sides), and Out on the pair eases only +/// out sides. +#[test] +fn a_selected_pair_eases_the_sides_its_mode_names() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].in_ease[0], 200.0, 0.6)); + assert!(eased(op.keys[1].out_ease[0], -20.0, 0.3), "the side facing the unselected key: {:?}", op.keys[1].out_ease); + assert_eq!(op.keys[2], before.keys[2]); + s.execute("edit.undo", json!({})).unwrap(); + apply_typed(&mut s, win, "Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].out_ease[0], -20.0, 0.3)); + assert_eq!((op.keys[0].in_interp, op.keys[1].in_interp), (Interp::Linear, Interp::Linear)); +} + +/// Easing one side of a key leaves the other playing as it did: a Hold stays a Hold, and an +/// auto-Bezier side keeps its shape. A preset click uses the chosen sides too. +#[test] +fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var t = app.project.activeItem.layer(1).transform, K = KeyframeInterpolationType; + t.opacity.setInterpolationTypeAtKey(2, K.LINEAR, K.HOLD); + t.rotation.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + t.rotation.setTemporalAutoBezierAtKey(2, true);"#, + ); + let rot = prop(&s, l, "transform/rotation"); + let after: Vec = [1.5, 2.0, 2.5].iter().map(|t| rot.value_at(at(*t)).as_f64()).collect(); + let win = open_panel(&mut s); + set(&mut s, win, "mode", json!("In")); + select_keys(&mut s, l, &[("transform/opacity", 1.0), ("transform/rotation", 1.0)]); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In) with \u{201c}Ease In-Out\u{201d}"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[1].in_interp, op.keys[1].out_interp), (Interp::Bezier, Interp::Hold)); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5), "{:?}", op.keys[1].in_ease); + assert_eq!(op.value_at(at(2.0)).as_f64(), 100.0, "still holds"); + let eased_rot = prop(&s, l, "transform/rotation"); + assert!(eased(eased_rot.keys[1].in_ease[0], 0.0, 0.5), "{:?}", eased_rot.keys[1].in_ease); + for (t, want) in [1.5, 2.0, 2.5].iter().zip(after) { + assert!(close(eased_rot.value_at(at(*t)).as_f64(), want, 1e-6), "the auto-Bezier out side at {t}"); + } + assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed"); +} + +/// On one key, each side scales to its own segment: per dimension, and along the motion path. +#[test] +fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + // Scale: +100/−50 per second into the key, 0/+50 per second out of it. + animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (3.0, json!([200, 150]))]); + // Position on a straight path: 100 px/s into the key, 50 px/s out of it. + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([200, 0, 0]))]); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/scale", 1.0), ("transform/position", 1.0)]); + apply_typed(&mut s, win, "In-Out", [40.0, 1.5, 50.0, 2.0]); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In-Out) with the curve"); + let k = prop(&s, l, "transform/scale").keys[1].clone(); + assert!(eased(k.in_ease[0], 200.0, 0.5) && eased(k.in_ease[1], -100.0, 0.5), "{:?}", k.in_ease); + assert!(eased(k.out_ease[0], 0.0, 0.4) && eased(k.out_ease[1], 75.0, 0.4), "{:?}", k.out_ease); + let k = prop(&s, l, "transform/position").keys[1].clone(); + assert!(k.in_ease.iter().all(|e| close(e.speed, 200.0, 0.5) && close(e.influence, 0.5, 1e-9)), "{:?}", k.in_ease); + assert!(k.out_ease.iter().all(|e| close(e.speed, 75.0, 0.5) && close(e.influence, 0.4, 1e-9)), "{:?}", k.out_ease); + let pos = prop(&s, l, "transform/position"); + assert_eq!(pos.value_at(at(1.0)).as_vec2()[0], 100.0, "the key's value stays"); +} + #[test] fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() { let store = Arc::new(MemoryConfig::default()); From c0f919a8589ad49e6dd0603cd9e2ab5b5e35d240 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:50 -0400 Subject: [PATCH 030/185] Docs: plugins.md describes the Ease Presets In, Out and In-Out sides (#1329) --- docs/plugins.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index fc7c0b8f..861739b8 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -103,17 +103,22 @@ name in your Scripts folders takes their place. ### Ease Presets (`extensions/scriptui-panels/Ease Presets.jsx`) -Window ▸ Ease Presets.jsx: easing curves kept by name and applied to pairs of neighbouring -selected keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's +Window ▸ Ease Presets.jsx: easing curves kept by name and applied to the sides of selected +keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's first key and the in side of its second, in the Keyframe Velocity dialog's terms: influence in percent, and speed relative to the segment's average speed (1 = as fast as a straight line, 0 = at rest), so one curve fits any duration and change of value. -* Click a preset to apply it to every pair of neighbouring selected keyframes of each selected - property, in one undo step (per dimension, and along the motion path for spatial properties; - the keys' other sides keep playing as they did). The keys stay selected, so you can try another. +* Click a preset to apply it to the selected keyframes of each selected property, in one undo + step. **Ease** chooses the sides (#1329): **In** eases each key's incoming side with the curve's + in side, **Out** its outgoing side with the curve's out side, **In-Out** (the default) both. A + side is eased when it has a neighbouring keyframe, selected or not (the neighbour doesn't + change), with its speeds scaled to the segment on that side: per dimension, and along the + motion path for spatial properties. So a single keyframe works, a selected pair gets the whole + curve, and the first and last keys ease the side that has a segment. The side not eased keeps + playing as it did (a Hold stays a Hold). The keys stay selected, so you can try another. * The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and - click Apply, or click From Keys to read the curve between the first selected pair. + click Apply, or click From Keys to read the curve between the first selected pair of neighbouring keys. * Save keeps the working curve under the typed name (replacing a preset of that name); Rename and Delete act on the selected preset of your own. The twelve built-in presets can't change. * User presets are kept with `app.settings` (section `Ease Presets`, key `userPresets`, in From 03b7a958e5c3a59d0a63315c0948f3f69e2f1d50 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:15:54 +0200 Subject: [PATCH 031/185] Solo: exclude other layers even when the soloed layer is outside its time range In After Effects solo is not time-dependent: while any AV layer is soloed, non-soloed layers are excluded at every time. The renderer, nested/collapsed 3D paths, engine viewer helpers and viewer hit tests only counted solo layers active at the current time, so outside the soloed layer's in-out range the whole comp reappeared. Drop the time condition (audio already ignored it). Update the #66 viewer regression test to the AE behaviour and add a render test for it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/viewer.rs | 2 +- crates/render/src/lib.rs | 2 +- crates/render/src/tests.rs | 18 ++++++++++++++++++ crates/render/src/three_d/adv/scene.rs | 2 +- crates/render/src/three_d/compose.rs | 2 +- crates/ui-egui/src/panels/viewer.rs | 5 +++-- crates/ui-egui/tests/ui_viewer.rs | 6 ++++-- docs/gaps.md | 3 ++- 8 files changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index cac75024..997c2023 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -362,7 +362,7 @@ const MAX_COLLAPSED_DEPTH: usize = 8; fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterator { let comp = ctx.comp; let time = ctx.time; - let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time)); + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id)) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6..bf6eebd9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2086,7 +2086,7 @@ impl<'a> Renderer<'a> { /// applied). pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> { let (comp, t) = (ctx.comp, ctx.time); - let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); comp.layers .iter() .rev() diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 39e022b0..a38b580f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -953,3 +953,21 @@ fn shape_content_bounds_are_the_visual_edges() { assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}"); } } + +/// Solo is not time-dependent (as in After Effects): while any layer is soloed, the others stay +/// excluded even when the playhead is outside the soloed layer's in-out range. +#[test] +fn solo_excludes_other_layers_outside_the_soloed_layers_range() { + let (mut p, cid, comp) = setup(); + let a = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let mut b = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); + b.in_point = Tick::from_seconds_f64(1.0); + b.switches.solo = true; + let c = p.comp_mut(cid).unwrap(); + c.layers.push(b); + c.layers.push(a); + let first = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0); + assert_eq!(first.get(100, 50), [0.0, 0.0, 0.0, 0.0], "neither A nor B before B's in point"); + let second = render_frame(&p, cid, Tick::from_seconds_f64(1.5), 1.0); + assert_eq!(second.get(100, 50), [0.0, 0.0, 1.0, 1.0], "only B inside its range"); +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 5b5ae956..34517933 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -873,7 +873,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; - let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); + let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); for l in nctx.comp.layers.iter().rev() { if !l.is_active_at(t) || l.is_light() diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 0d70b32f..831ab3ec 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -497,7 +497,7 @@ fn collapsed_items<'a>( let op = ctx.opacity(layer) as f32 * r.opacity_mul(); let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; let t = nctx.time; - let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); + let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); let nested: Vec<&'a Layer> = nctx .comp .layers diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..62aca538 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2283,9 +2283,10 @@ fn pen_press( } /// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. -/// Solo scope matches the renderer: only active AV layers activate it, even when hidden or locked. +/// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After +/// Effects), even when hidden, locked or outside its in-out range. pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { - let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time)); + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); comp.layers .iter() .filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1..53d6d206 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -121,8 +121,10 @@ fn viewer_click_does_not_pick_through_hidden_or_locked_solo_layers() { } } +/// Solo is not time-dependent (as in After Effects): outside the soloed layer's in-out range the +/// viewer shows nothing, so neither the soloed layer nor the others can be picked. #[test] -fn viewer_click_ignores_inactive_solo_layers() { +fn viewer_click_picks_nothing_while_the_solo_layer_is_inactive() { let mut h = selection_harness(); let comp = h.state().session.active_comp().unwrap(); let (front, back) = (comp.layers[0].id, comp.layers[1].id); @@ -133,7 +135,7 @@ fn viewer_click_ignores_inactive_solo_layers() { h.run_steps(3); let at = screen(&h, [320.0, 180.0]); click(&mut h, at); - assert_eq!(h.state().session.state.selected_layers, vec![back]); + assert!(h.state().session.state.selected_layers.is_empty(), "{back:?} stays excluded by solo"); } /// A selected layer under the pointer takes the press before the layers in front of it, as in diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fb..64f6f413 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -117,7 +117,8 @@ The most important missing piece: it turns every other estimate here into a meas - Regression evidence for [#66](https://github.com/storytold/effectcraft/issues/66): scripted pointer tests in `ui_viewer` and `ui_text_edit`, checked headlessly on Linux, cover viewer clicks, marquee selection and text picking when layers are hidden or excluded by solo. They also check - hidden/locked solo layers and solo layers outside their active time range. + hidden/locked solo layers, and that solo still excludes the other layers while the soloed + layer is outside its active time range (as in After Effects, so nothing is picked there). - Regression evidence for [#67](https://github.com/storytold/effectcraft/issues/67): scripted input tests in `ui_project_delete`, checked headlessly on Linux, cover Delete/Backspace on Project items, multiple selection and undo. They check that an unrelated Timeline layer stays, From f62af4ca73bbb33a4d1e1ab5602cfac101e29683 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:55:43 +0200 Subject: [PATCH 032/185] Guide layers: hidden in nested comps, and in the Timeline context menu, as in After Effects Guide layers render only in their own comp's viewer: a nested renderer (precomp, collapsed precomp in 2D, Classic 3D and Advanced 3D, comps read by effects, aux passes, the GPU walk's resident precomps) now runs with guides off (Renderer::nested_opts), so a precomp's guide layers no longer show in the parent comp's viewer or in a "Current Settings" render. The Timeline layer and Composition viewer right-click menus (shared layer_menu) gain a Guide Layer item (a plain toggle, no check mark) acting on the selection, and guide layers show a guide mark before their name in the Timeline. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 18 +++- crates/render/src/tests_guides.rs | 108 ++++++++++++++++++++ crates/render/src/three_d/adv/scene.rs | 2 +- crates/render/src/three_d/compose.rs | 2 +- crates/ui-egui/src/i18n/ui.rs | 7 ++ crates/ui-egui/src/panels/timeline.rs | 48 +++++++-- crates/ui-egui/src/panels/viewer.rs | 2 +- crates/ui-egui/tests/ui_guide_layers.rs | 129 ++++++++++++++++++++++++ 8 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 crates/render/src/tests_guides.rs create mode 100644 crates/ui-egui/tests/ui_guide_layers.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6..45581d7f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -533,7 +533,9 @@ pub struct RenderOpts { /// Output scale relative to comp pixels (1 = Full, 0.5 = Half…). pub scale: f64, pub motion_blur: bool, - /// Render guide layers (viewer only). + /// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested + /// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in + /// After Effects (see [`Renderer::nested_opts`]). pub guides: bool, /// Draft quality: fewer motion-blur samples, bilinear only. pub draft: bool, @@ -737,9 +739,16 @@ impl<'a> Renderer<'a> { sub } + /// The render options one level down (precomps, collapsed precomps, comps read by effects): + /// guide layers render only in their own comp, never when it is nested. + fn nested_opts(&self) -> RenderOpts { + RenderOpts { guides: false, ..self.opts } + } + /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { Renderer { + opts: self.nested_opts(), depth: self.depth + 1, outer: None, opacity_mul: 1.0, @@ -830,7 +839,10 @@ impl<'a> Renderer<'a> { if let Some(slot) = slot { *slot = Some(link); } - Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx)) + Some(( + Renderer { opts: self.nested_opts(), depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, + nctx, + )) } pub(crate) fn opacity_mul(&self) -> f32 { @@ -2388,6 +2400,8 @@ mod tests_footage_size; #[cfg(test)] mod tests_frame_blend; #[cfg(test)] +mod tests_guides; +#[cfg(test)] mod tests_mask_blur; #[cfg(test)] mod tests_nested; diff --git a/crates/render/src/tests_guides.rs b/crates/render/src/tests_guides.rs new file mode 100644 index 00000000..cb985125 --- /dev/null +++ b/crates/render/src/tests_guides.rs @@ -0,0 +1,108 @@ +//! Guide layers: drawn in their own comp's viewer (`RenderOpts::guides`), never when that comp is +//! nested in another (precomp, collapsed precomp, 2D or 3D), and never in final renders. + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), three_d: bool) -> Layer { + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + l.switches.three_d = three_d; + l +} + +#[derive(Clone, Copy, Debug)] +enum Nest { + /// A plain precomp layer (the nested comp rendered on its own, then composited). + Precomp, + /// Collapse Transformations on a 2D precomp layer. + Collapsed, + /// A collapsed 3D precomp in a Classic 3D comp. + Collapsed3d, + /// A collapsed 3D precomp in an Advanced 3D comp. + CollapsedAdvanced, +} + +/// "Inner": a red 100×100 background with a green 40×40 guide layer in the middle; "Outer": the +/// same size, holding only Inner nested as `nest`. +fn scene(nest: Nest) -> (Project, ItemId, ItemId) { + let three_d = matches!(nest, Nest::Collapsed3d | Nest::CollapsedAdvanced); + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut guide = solid(&mut p, &inner, [0.0, 1.0, 0.0], (40, 40), three_d); + guide.switches.guide = true; + let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d); + let mut inner = inner; + inner.layers = vec![guide, bg]; + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + if matches!(nest, Nest::CollapsedAdvanced) { + outer.renderer = effectcraft_project::Renderer::Advanced3D; + } + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + pre.switches.collapse = !matches!(nest, Nest::Precomp); + pre.switches.three_d = three_d; + outer.layers = vec![pre]; + let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + (p, iid, oid) +} + +/// The centre pixel of `comp` rendered with or without guide layers. +fn centre(p: &Project, comp: ItemId, guides: bool) -> [f32; 4] { + Renderer::new(p, &NoFootage, RenderOpts { guides, ..Default::default() }).comp_frame(comp, Tick::ZERO).get(50, 50) +} + +fn is_green(c: [f32; 4]) -> bool { + c[1] > 0.9 && c[0] < 0.1 +} + +fn is_red(c: [f32; 4]) -> bool { + c[0] > 0.9 && c[1] < 0.1 +} + +#[test] +fn guide_layer_renders_in_its_own_comp_with_guides_on() { + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, true); + assert!(is_green(c), "guide layer drawn in its own comp's viewer: {c:?}"); +} + +#[test] +fn final_renders_leave_guide_layers_out_by_default() { + assert!(!RenderOpts::default().guides); + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, false); + assert!(is_red(c), "guide layer left out of final renders: {c:?}"); +} + +#[test] +fn guide_layers_of_a_nested_comp_never_render_in_the_parent() { + for nest in [Nest::Precomp, Nest::Collapsed, Nest::Collapsed3d, Nest::CollapsedAdvanced] { + let (p, inner, outer) = scene(nest); + // The nested comp's own viewer still shows them… + let own = centre(&p, inner, true); + assert!(is_green(own), "{nest:?}: guide layer in its own comp: {own:?}"); + // …but not the parent's viewer (guides on) nor its final render. + for guides in [true, false] { + let c = centre(&p, outer, guides); + assert!(is_red(c), "{nest:?}, guides {guides}: nested guide layer drawn in the parent: {c:?}"); + } + } +} + +#[test] +fn a_guide_precomp_layer_of_the_top_comp_follows_the_guides_option() { + // The precomp layer itself is a guide layer in the top comp: shown in the viewer, not in + // final renders. + let (mut p, _, outer) = scene(Nest::Precomp); + p.comp_mut(outer).unwrap().layers[0].switches.guide = true; + assert!(is_red(centre(&p, outer, true))); + assert!(centre(&p, outer, false)[3] < 0.01, "nothing drawn without guides"); +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 5b5ae956..f1f4c185 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -879,7 +879,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: || l.is_light() || matches!(l.source, LayerSource::Camera) || (any_solo && !l.switches.solo) - || (!r.opts.guides && l.switches.guide) + || (!sub.opts.guides && l.switches.guide) { continue; } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 0d70b32f..ca7998fe 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -503,7 +503,7 @@ fn collapsed_items<'a>( .layers .iter() .rev() - .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide)) + .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (sub.opts.guides || !l.switches.guide)) .collect(); let mut items = Vec::new(); for (k, l) in nested.into_iter().enumerate() { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4af..64a1b6d1 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -262,6 +262,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Grab bag", "いろいろ"), ("Gradient Editor", "グラデーションエディター"), ("Grid", "グリッド"), + ("Guide Layer", "ガイドレイヤー"), ("Guide Layers: {}", "ガイドレイヤー:{}"), ("Guides", "ガイド"), ("Height:", "高さ:"), @@ -806,6 +807,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "列表"), ("Gradient Editor", "渐变编辑器"), ("Grid", "网格"), + ("Guide Layer", "参考线图层"), ("Guide Layers: {}", "参考图层:{}"), ("Guides", "参考线"), ("Height:", "高度:"), @@ -1538,6 +1540,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "清單"), ("Gradient Editor", "漸變編輯器"), ("Grid", "格點"), + ("Guide Layer", "參考圖層"), ("Guide Layers: {}", "參考圖層:{}"), ("Guides", "參考線"), ("Height:", "高度:"), @@ -2273,6 +2276,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Grab bag", "Список"), ("Gradient Editor", "Редактор градієнта"), ("Grid", "Сітка"), + ("Guide Layer", "Допоміжний шар"), ("Guide Layers: {}", "Допоміжні шари: {}"), ("Guides", "Напрямні"), ("Height:", "Висота:"), @@ -2823,6 +2827,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Frame Target:", "Destino do quadro:"), ("Grab bag", "Lista"), ("Grid", "Grade"), + ("Guide Layer", "Camada de guia"), ("Guide Layers: {}", "Camadas de guia: {}"), ("Guides", "Guias"), ("Height:", "Altura:"), @@ -3274,6 +3279,7 @@ const CZECH: &[(&str, &str)] = &[ ("Freeze", "Zmrazit"), ("Grab bag", "Mozaika"), ("Grid", "Mřížka"), + ("Guide Layer", "Pomocná vrstva"), ("Guide Layers: {}", "Pomocné vrstvy: {}"), ("Guides", "Vodítka"), ("Height:", "Výška:"), @@ -4310,6 +4316,7 @@ mod tests { set_current("cs"); assert_eq!(tr("Composition Name:"), "Název kompozice:"); assert_eq!(tr("Cancel"), "Zrušit"); + assert_eq!(tr("Guide Layer"), "Pomocná vrstva", "the layer context menus' item, as Layer ▸ Guide Layer"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Použití zástupných souborů: Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Krok 1 z 3: Colour"); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..d00dbd5e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2042,7 +2042,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl"); icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim); - let name_rect = Rect::from_min_max(pos2(cw.name + 34.0, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); + // A guide layer carries a guide mark before its name (After Effects). + let name_x = if layer.switches.guide { + let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0)); + icons::paint(&lp, mark, Icon::Grid, t.text_dim); + app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer"); + cw.name + 50.0 + } else { + cw.name + 34.0 + }; + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); let rename_id = egui::Id::new("tl-rename"); let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id)); if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) { @@ -2103,7 +2112,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), @@ -2324,7 +2333,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); let drag_key = format!("bar-{}", layer.id.0); // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the // drag starts and followed below the rows until the button is released. @@ -3857,12 +3866,14 @@ impl LayerMenuCaps { /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part /// of the selection keeps the selection and the menu acts on all of it; right-clicking an /// unselected layer selects only that layer. +#[allow(clippy::too_many_arguments)] fn layer_context_menu( resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], caps: LayerMenuCaps, + auto: &mut crate::automation::Registry, actions: &mut Vec<(String, serde_json::Value)>, ) { let in_selection = selected.contains(&layer.id); @@ -3870,12 +3881,24 @@ fn layer_context_menu( actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); } let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; - crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, actions)); + crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions)); } -/// Shared layer actions for Timeline rows and Composition context menus. -pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { +/// The layer switches the layer context menus toggle (After Effects lists them after Time): +/// `layer.setSwitch` name and label. +const MENU_SWITCHES: [(&str, &str); 1] = [("guide", "Guide Layer")]; + +/// Shared layer actions for Timeline rows and Composition context menus. `auto` is the automation +/// registry and the id prefix of the menu's items. +pub(crate) fn layer_menu( + ui: &mut egui::Ui, + layers: &[u64], + caps: LayerMenuCaps, + auto: (&mut crate::automation::Registry, &str), + actions: &mut Vec<(String, serde_json::Value)>, +) { let Some(&id) = layers.first() else { return }; + let (auto, prefix) = auto; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { if ui.button(label).clicked() { actions.push(("layer.select".into(), json!({"layers": layers}))); @@ -3921,6 +3944,18 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, ui.menu_button(crate::i18n::tr("Markers"), |ui| { item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); }); + // A click toggles the switch on the target layers, as Layer ▸ Guide Layer does. + // (Pushed after the other items: `item` holds `actions` until its last use.) + let mut switch_actions: Vec<(String, serde_json::Value)> = Vec::new(); + for (switch, label) in MENU_SWITCHES { + let response = ui.button(crate::i18n::tr(label)); + auto.add(&format!("{prefix}.switch.{switch}"), response.rect, label); + if response.clicked() { + switch_actions.push(("layer.select".into(), json!({"layers": layers}))); + switch_actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": switch}))); + ui.close(); + } + } ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| { widgets::menu_scroll(ui, |ui| { for m in BlendMode::ALL { @@ -3943,6 +3978,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, item(ui, "Duplicate", "edit.duplicate", json!({"layers": layers})); item(ui, "Split Layer", "edit.splitLayer", json!({"layers": layers})); item(ui, "Delete", "edit.clear", json!({"layers": layers})); + actions.extend(switch_actions); } /// The layer context menu's Effects submenu: every effect from the registry grouped by category, diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..324ed3dd 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1274,7 +1274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { caps }, ); - super::timeline::layer_menu(ui, &context_layers, caps, &mut context_actions); + super::timeline::layer_menu(ui, &context_layers, caps, (&mut app.auto, "viewer.layerMenu"), &mut context_actions); } ui.separator(); if ui.button(copy_frame).clicked() { diff --git a/crates/ui-egui/tests/ui_guide_layers.rs b/crates/ui-egui/tests/ui_guide_layers.rs new file mode 100644 index 00000000..37158658 --- /dev/null +++ b/crates/ui-egui/tests/ui_guide_layers.rs @@ -0,0 +1,129 @@ +//! Guide Layer in the layer context menus (Timeline rows and the Composition viewer), a plain item +//! (no check mark) that toggles the switch on the selection, and the guide mark by a guide layer's +//! name. +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use serde_json::json; + +/// A 640×360 comp with two 200×200 solids, "Left" at (160, 180) and "Right" at (480, 180). +fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, LayerId) { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Guides", "width": 640, "height": 360, "duration": 4})).unwrap(); + let mut solid = |name: &str, x: f64| { + let id = session.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + session.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [x, 180.0]})).unwrap(); + LayerId(id) + }; + let left = solid("Left", 160.0); + let right = solid("Right", 480.0); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(3); + (h, left, right) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn guide(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> bool { + h.state().session.active_comp().unwrap().layer(id).unwrap().switches.guide +} + +fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) { + if let Ok(dir) = std::env::var("GUIDE_LAYER_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + } +} + +/// The open menu's Guide Layer item carries no check mark (a plain toggle). +fn plain(h: &Harness<'_, EffectcraftApp>) -> bool { + !h.get_by_label_contains("Guide Layer").accesskit_node().label().unwrap_or_default().contains('✔') +} + +fn row(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Pos2 { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) +} + +/// The Guide Layer item of the open menu `prefix`. +fn guide_item(h: &Harness<'_, EffectcraftApp>, prefix: &str) -> Rect { + rect(h, &format!("{prefix}.switch.guide")) +} + +#[test] +fn timeline_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_none(), "no guide mark yet"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![left], "right-click selects the row"); + assert!(plain(&h), "no check mark"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left), "Guide Layer turned on"); + assert!(!guide(&h, right), "only the target layer"); + // The guide mark shows by the name. + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_some(), "guide mark by the name"); + // Again: off. Undo brings it back. + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert!(plain(&h), "no check mark on a guide layer either"); + snapshot(&mut h, "timeline-menu"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left), "Guide Layer turned off"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(guide(&h, left)); +} + +#[test] +fn timeline_layer_menu_guide_layer_acts_on_the_selection() { + let (mut h, left, right) = harness(); + h.state_mut().session.execute("layer.select", json!({"layers": [left.0, right.0]})).unwrap(); + h.run_steps(3); + let p = row(&h, right); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left) && guide(&h, right), "both selected layers become guide layers"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left) && !guide(&h, right), "and back together"); +} + +#[test] +fn viewer_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + let comp = rect(&h, "viewer.comp"); + let k = comp.width() / 640.0; + let at = |x: f32| comp.min + vec2(x, 180.0) * k; + click(&mut h, at(480.0), PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![right], "right-click picks the layer under the pointer"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, right) && !guide(&h, left)); + // The Timeline and viewer menus share the item: the viewer menu turns it off again. + click(&mut h, at(480.0), PointerButton::Secondary); + assert!(plain(&h), "no check mark"); + snapshot(&mut h, "viewer-menu"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, right)); +} From 9b74dba2a89d647be8b7300c91d4278b4378bbc7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 033/185] Ease Presets: skip roving keyframes and say so, rather than reporting them eased (#1329) A roving key passes speed straight through: the engine keeps both its sides linear whenever the keys are edited (retime_roving), so an ease set on one never plays. The panel still counted it as eased, so a selected roving key showed "Eased 1 keyframe(s)" and stayed linear. The previous pair-only panel did the same to a pair holding a roving key. Selected roving keys are now left out of the work and counted. The status names how many were skipped and that turning off Roving lets them ease; when nothing else was selected, nothing changes and no undo step opens. Fixed keys next to a roving key still ease. --- crates/script/src/tests_ease_presets.rs | 35 +++++++++++++++++++++ extensions/scriptui-panels/Ease Presets.jsx | 22 ++++++++----- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index 4d05d7c6..c9871d14 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -378,6 +378,41 @@ fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() { assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed"); } +/// Roving keys pass speed straight through (the engine keeps their sides linear), so an ease set +/// on one wouldn't play: they are skipped and the panel says so, rather than reporting them eased. +#[test] +fn roving_keyframes_are_skipped_and_reported() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([200, 0, 0]))]); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + ok(&mut s, "app.project.activeItem.layer(1).transform.position.setRovingAtKey(2, true)"); + let pos = prop(&s, l, "transform/position"); + assert!(pos.keys[1].roving); + let roving_at = pos.keys[1].time.seconds(); + let win = open_panel(&mut s); + // Only the roving key: nothing changes, no undo step. + select_keys(&mut s, l, &[("transform/position", roving_at)]); + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Nothing eased: skipped 1 roving keyframe(s); turn off Roving to ease them"); + assert_eq!(s.history.undo.len(), steps); + assert_eq!(prop(&s, l, "transform/position").keys, pos.keys); + // With an ordinary key: that one is eased, the roving one is counted as skipped. + select_keys(&mut s, l, &[("transform/position", roving_at), ("transform/opacity", 1.0)]); + set(&mut s, win, "presets", json!("Linear")); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!( + status(&mut s, win), + "Eased 1 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}; skipped 1 roving keyframe(s); turn off Roving to ease them" + ); + let k = &prop(&s, l, "transform/position").keys[1]; + assert!(k.roving && k.in_interp == Interp::Linear && k.out_interp == Interp::Linear, "{k:?}"); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5)); +} + /// On one key, each side scales to its own segment: per dimension, and along the motion path. #[test] fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index 0dd3f43a..6f6eadae 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -16,6 +16,8 @@ // its outgoing side with the curve's out side, In-Out both: a side is eased when it has a // neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the // segment on that side. So a selected pair gets the whole curve, and a single key works too. +// Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease +// set on them wouldn't play. // // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", @@ -197,16 +199,20 @@ } // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in - // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns the - // number of keyframes eased. + // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns + // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. function applyCurve(c, mode) { - var keyed = selectedKeyed(), sides = [], keys = 0; + var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; var doIn = mode !== "Out", doOut = mode !== "In"; // Every side and its eases first: the segments are measured before any key changes. for (var i = 0; i < keyed.length; i++) { var p = keyed[i].prop; for (var j = 0; j < keyed[i].keys.length; j++) { var k = keyed[i].keys[j], before = sides.length; + if (p.keyRoving(k)) { + roving++; + continue; + } var s = doIn && k > 1 ? slopes(p, k - 1) : null; if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); s = doOut && k < p.numKeys ? slopes(p, k) : null; @@ -214,14 +220,14 @@ if (sides.length > before) keys++; } } - if (!sides.length) return 0; + if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); } finally { app.endUndoGroup(); } - return keys; + return { eased: keys, roving: roving }; } // The curve between the first selected pair of keyframes (measured on the dimension that @@ -378,9 +384,11 @@ function apply(c, what) { var m = mode.selection ? mode.selection.text : "In-Out"; - var n = applyCurve(c, m); + var r = applyCurve(c, m), n = r.eased; var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; - status.text = n > 0 ? "Eased " + n + " keyframe(s) (" + m + ") with " + what : "Select keyframes with another keyframe " + side; + var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; + if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); + else status.text = skipped ? "Nothing eased: " + skipped : "Select keyframes with another keyframe " + side; return n; } From 4dddb34cbab47076a5e1f01e8bc90db526b4c28c Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 034/185] Ease Presets: tests for Out alone through undo and redo, and a single key on a curved path (#1329) Out on an auto-Bezier key leaves its in side playing the same values, and undo and redo restore each state. On a curved Position path with unequal segment lengths and durations, In sets the key's speed from the incoming path length, playback arrives faster than a constant speed, and the out side plays as before. --- crates/script/src/tests_ease_presets.rs | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index c9871d14..009cc59c 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -413,6 +413,65 @@ fn roving_keyframes_are_skipped_and_reported() { assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5)); } +/// Out alone leaves an auto-Bezier in side playing as it did, and undo and redo restore each state. +#[test] +fn out_alone_keeps_the_in_side_playing_through_undo_and_redo() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var r = app.project.activeItem.layer(1).transform.rotation, K = KeyframeInterpolationType; + r.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + r.setTemporalAutoBezierAtKey(2, true);"#, + ); + let before = prop(&s, l, "transform/rotation"); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/rotation", 1.0)]); + apply_typed(&mut s, win, "Out", [60.0, 0.0, 33.0, 1.0]); + let after = prop(&s, l, "transform/rotation"); + assert!(eased(after.keys[1].out_ease[0], 0.0, 0.6), "{:?}", after.keys[1].out_ease); + for t in [0.25, 0.5, 0.75] { + assert!(close(after.value_at(at(t)).as_f64(), before.value_at(at(t)).as_f64(), 1e-6), "the in side at {t}"); + } + assert!(!close(after.value_at(at(2.0)).as_f64(), before.value_at(at(2.0)).as_f64(), 1e-3), "the out side changed"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, before.keys); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, after.keys); +} + +/// On a curved path, a single key's in and out speeds follow the length of the path on each side +/// (unequal lengths and durations), and the side not eased plays as it did. +#[test] +fn a_single_key_on_a_curved_path_eases_along_each_side() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([100, 300, 0]))]); + let before = prop(&s, l, "transform/position"); + let (len_in, len_out) = (effectcraft_keyframe::spatial_segment_length(&before.keys, 0), effectcraft_keyframe::spatial_segment_length(&before.keys, 1)); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/position", 1.0)]); + apply_typed(&mut s, win, "In", [40.0, 1.5, 50.0, 2.0]); + let pos = prop(&s, l, "transform/position"); + let k = &pos.keys[1]; + // In speed: twice the incoming segment's average (its path length over 1 s). + assert!(k.in_ease.iter().all(|e| close(e.speed, 2.0 * len_in, 0.05 * len_in) && close(e.influence, 0.5, 1e-9)), "{:?} vs {len_in}", k.in_ease); + // The out side (2 s along a longer path) plays as before. + for t in [1.5, 2.0, 2.5] { + let (a, b) = (pos.value_at(at(t)).as_vec2(), before.value_at(at(t)).as_vec2()); + assert!(close(a[0], b[0], 1e-6) && close(a[1], b[1], 1e-6), "out side at {t}: {a:?} vs {b:?}"); + } + // Playback on the in side: arriving at twice the average speed, the last tenth of a second + // covers more of the path than a constant speed would (len_in / 10). + let (p9, p10) = (pos.value_at(at(0.9)).as_vec2(), pos.value_at(at(1.0)).as_vec2()); + let step = ((p10[0] - p9[0]).powi(2) + (p10[1] - p9[1]).powi(2)).sqrt(); + assert!(step > len_in / 10.0 * 1.2, "{step} vs {len_in}"); + assert!(len_out > len_in, "{len_out} {len_in}"); +} + /// On one key, each side scales to its own segment: per dimension, and along the motion path. #[test] fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { From a37357f082a77df31d8c438cb2e2f9560142ee50 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 035/185] Docs: plugins.md notes that Ease Presets skips roving keyframes (#1329) --- docs/plugins.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plugins.md b/docs/plugins.md index 861739b8..a688a89b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -116,7 +116,9 @@ rest), so one curve fits any duration and change of value. change), with its speeds scaled to the segment on that side: per dimension, and along the motion path for spatial properties. So a single keyframe works, a selected pair gets the whole curve, and the first and last keys ease the side that has a segment. The side not eased keeps - playing as it did (a Hold stays a Hold). The keys stay selected, so you can try another. + playing as it did (a Hold stays a Hold). Roving keyframes are skipped, and the status says how + many: they pass speed straight through, so an ease on one wouldn't play (turn off Roving to + ease it). The keys stay selected, so you can try another. * The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and click Apply, or click From Keys to read the curve between the first selected pair of neighbouring keys. * Save keeps the working curve under the typed name (replacing a preset of that name); Rename From fdc515f5bea165e6cf304890443f0e861026fb56 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 21:58:52 +0200 Subject: [PATCH 036/185] #1063: find an undefined name's first use from the failing frame on A ReferenceError's first use was searched across the whole file, so a mention earlier in a comment, a string or another function could report the wrong line. The search now starts at the innermost script frame's line, and covers the whole file only when there is no frame. Follow-up to storytold/effectcraft#1161. Co-Authored-By: Claude Haiku 5.5 --- crates/script/src/runtime.rs | 19 ++++++++++++------- crates/script/src/tests.rs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index b94bd80f..ce51c573 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -563,27 +563,32 @@ fn position(msg: &str, file: &str, code: &str) -> (Option, Option) { let mut it = l[i..].split([':', ')']); Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?)) }; + // The innermost frame in the user's script, or the error's own position. + let frames: Vec<&str> = msg.lines().skip(1).collect(); + let frame = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)); // `ReferenceError: name is not defined`: boa places it at the start of the function that reads - // the name, so the first use of the name in the code is the better position (#1063). + // the name, so the name's first use from that frame's line on is the better position (#1063). + // With no frame to start from, the first use anywhere in the code is (#1161). let first = msg.lines().next().unwrap_or(""); let undefined = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()); - if let Some(at) = undefined.and_then(|name| first_use(name, code)) { + let start_line = frame.map_or(1, |(line, _)| line); + if let Some(at) = undefined.and_then(|name| first_use(name, code, start_line)) { return (Some(at.0), Some(at.1)); } - let frames: Vec<&str> = msg.lines().skip(1).collect(); - if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) { + if let Some((a, b)) = frame { return (Some(a), Some(b)); } (None, None) } -/// The first use of `name` as a whole identifier in `code`, as a 1-based line and column. -fn first_use(name: &str, code: &str) -> Option<(u32, u32)> { +/// The first use of `name` as a whole identifier in `code` on or after `start_line` (1-based), as a +/// 1-based line and column. +fn first_use(name: &str, code: &str, start_line: u32) -> Option<(u32, u32)> { if name.is_empty() { return None; } let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$'; - for (n, line) in code.lines().enumerate() { + for (n, line) in code.lines().enumerate().skip(start_line.saturating_sub(1) as usize) { let b = line.as_bytes(); let mut from = 0; while let Some(i) = line.get(from..).and_then(|rest| rest.find(name)).map(|i| i + from) { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 2c821e6a..365c72af 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -187,6 +187,18 @@ fn errors_inside_a_function_wrapper_report_the_failing_line() { assert_eq!(e.line, Some(3), "{e:?}"); } +/// The first use of an undefined name is the one inside the function that reads it: a mention +/// before it, in a comment, a string or another function, doesn't move the report (#1161). +#[test] +fn an_undefined_name_mentioned_earlier_reports_its_use_in_the_reading_function() { + let mut s = session(); + let code = "// nope is mentioned here\nvar text = 'nope';\nfunction other() { return nope; }\n(function () {\n var a = 1;\n nope.call();\n})();\n"; + let o = run_code(&mut s, code, "earlier.jsx"); + let e = o.error.expect("reference error"); + assert!(e.message.contains("nope"), "{e:?}"); + assert_eq!(e.line, Some(6), "{e:?}"); +} + #[test] fn match_names_and_lookups() { let mut s = session(); From 7a9ca5810a5e8392a88abbe09d33aa8289112a65 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 14:47:51 -0600 Subject: [PATCH 037/185] Scripts: a script can't confirm executing a file for itself (#1246) With Warn User When Executing Files on, file.executeFile asks the frontend first, and the frontend's answer re-runs it with `confirmed: true`. That flag was taken from the parameters whoever called, so a script could pass it itself (app.run('file.executeFile', {path, confirmed: true}), also inside engine.batch) and the file opened without the question, as long as the user had allowed file and network access. `confirmed` now counts only when the command isn't running for a script (Session::running_for_script, which #590 uses for the same reason and which stays set through nested batches). A script's request always gets the question; the user's answer from the frontend's form still opens the file; with the warning off nothing changes. The permission gate itself is unchanged. Tests: engine a_script_cannot_confirm_executing_a_file_for_itself (execute_for_script directly and through engine.batch; the frontend's confirmation; warning off) and the same through real JavaScript in the script crate (app.run, app.run('engine.batch'), File.execute()). Both fail on main. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/app_more.rs | 6 ++++-- crates/engine/src/tests_m145.rs | 28 ++++++++++++++++++++++++++ crates/script/src/tests.rs | 26 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 020880e8..1bb94952 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -245,7 +245,8 @@ fn has_footage(s: &Session) -> std::result::Result<(), String> { /// `File.execute()` from scripts (and agents): open a file with its default application. /// Needs Allow Scripts to Write Files and Access Network; with Warn User When Executing Files on, -/// the frontend asks first (`confirmed: true` skips the question). +/// the frontend asks first. `confirmed: true` is the frontend's answer to that question: a +/// script can't give it for itself (#1246), so a script's request always asks. fn execute_file(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.executeFile", "missing `path`"))?; if !s.prefs.scripting.allow_scripts_write_files { @@ -257,7 +258,8 @@ fn execute_file(s: &mut Session, p: &Value) -> Result { if !std::path::Path::new(path).exists() { return Err(bad("file.executeFile", format!("no file {path}"))); } - if s.prefs.scripting.warn_executing_files && !b_p(p, "confirmed").unwrap_or(false) { + let confirmed = b_p(p, "confirmed").unwrap_or(false) && !s.running_for_script(); + if s.prefs.scripting.warn_executing_files && !confirmed { s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) }); return Ok(json!({"needsConfirmation": path})); } diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index e52e3ffb..3c0423cd 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -349,6 +349,34 @@ fn execute_file_needs_the_gate_and_asks_first() { assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some()); } +#[test] +fn a_script_cannot_confirm_executing_a_file_for_itself() { + // Warn User When Executing Files: only the user answers the question (#1246). + let mut s = session(); + let f = tmp("script-run.txt"); + std::fs::write(&f, "x").unwrap(); + let path = f.to_string_lossy().to_string(); + let opened = |s: &mut Session| s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(_))); + // Without the gate a script can't run files at all, confirmed or not. + assert!(s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).is_err()); + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); + s.drain_events(); + // A script asserting `confirmed` still gets the question, directly or in a batch. + let r = s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).unwrap(); + assert!(r.get("needsConfirmation").is_some(), "{r}"); + assert!(!opened(&mut s)); + let steps = json!({"steps": [{"command": "file.executeFile", "params": {"path": path, "confirmed": true}}]}); + let r = s.execute_for_script("engine.batch", steps).unwrap(); + assert!(r.to_string().contains("needsConfirmation"), "{r}"); + assert!(!opened(&mut s)); + // The user's answer from the frontend's question still opens it. + assert!(s.execute("file.executeFile", json!({"path": path, "confirmed": true})).unwrap().get("opened").is_some()); + assert!(opened(&mut s)); + // With the warning off a script's request opens the file without a question, as before. + s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap(); + assert!(s.execute_for_script("file.executeFile", json!({"path": path})).unwrap().get("opened").is_some()); +} + // ---------------------------------------------------------------- Edit ▸ History, recent lists #[test] diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 2c821e6a..3eece60c 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -430,6 +430,32 @@ fn save_frame_to_png_writes_behind_the_file_gate() { let _ = std::fs::remove_dir_all(&dir); } +/// #1246: with Warn User When Executing Files on, a script that asserts `confirmed` itself still +/// gets the question; only the user's answer opens the file. +#[test] +fn a_script_cannot_confirm_executing_a_file_for_itself() { + let dir = std::env::temp_dir().join(format!("ec-script-exec-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("run.txt"); + std::fs::write(&f, "x").unwrap(); + let q = serde_json::to_string(&f.to_string_lossy()).unwrap(); + let mut s = session(); + // The user allows file access; the warning stays on (the default). + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); + s.drain_events(); + let opened = |s: &mut Session| s.drain_events().iter().any(|e| matches!(e, effectcraft_engine::Event::OpenUrl(_))); + for code in [ + format!("JSON.stringify(app.run('file.executeFile', {{path: {q}, confirmed: true}}))"), + format!("JSON.stringify(app.run('engine.batch', {{steps: [{{command: 'file.executeFile', params: {{path: {q}, confirmed: true}}}}]}}))"), + format!("new File({q}).execute()"), + ] { + let o = ok(&mut s, &code); + assert!(!o.result.to_string().contains("opened") || o.result == json!(false), "{code}: {:?}", o.result); + assert!(!opened(&mut s), "{code} opened the file without asking"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// #590: a script can't give itself file and network access (or turn off the warning before /// executing files) by changing Settings ▸ Scripting & Expressions; the user still can. #[test] From 8fea81445c24e528b3444cea2630a45b1d91edba Mon Sep 17 00:00:00 2001 From: Daniel van Lith Date: Wed, 14 Oct 2026 10:36:50 +1300 Subject: [PATCH 038/185] macOS DMG: hdiutil create -srcfolder, so the app inside passes codesign --strict makehybrid -hfs gave every file in the image a com.apple.FinderInfo, so the app in the DMG, and a copy installed from it, failed codesign --verify --strict --deep ("resource fork, Finder information, or similar detritus not allowed"), though Gatekeeper accepts it. Build the image as VectorCraft (#1085) and PdfCraft (#823) do now: hdiutil create -srcfolder with a few tries for CI's "Resource busy", and verify the mounted app with --strict --deep so the regression can't ship again. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/package.sh | 39 +++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 607290bb..e6427d02 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -138,15 +138,40 @@ resvg --skip-system-fonts -w 1320 "$HERE/dmg/background.svg" "$WORK/background@2 sips -s dpiWidth 144 -s dpiHeight 144 "$WORK/background@2x.png" >/dev/null tiffutil -cathidpicheck "$WORK/background.png" "$WORK/background@2x.png" -out "$STAGE/.background/background.tiff" cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" -rm -f "$DMG" "$WORK/raw.dmg" -# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, -# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -# The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "EffectCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" -hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" -rm -f "$WORK/raw.dmg" +rm -f "$DMG" +# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a +# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or +# similar detritus" on the app in the image and on a copy installed from it (as VectorCraft's +# storytold/vectorcraft#1085 and PdfCraft's storytold/pdfcraft#823). `create` attaches a device +# while it copies, which fails now and then on CI runners ("Resource busy"), so it gets a few +# tries. The volume name has no version: .DS_Store finds the background through an alias that +# includes it. +# Usage: hdiutil_retry ; tries five times with growing pauses. +hdiutil_retry() { + local what="$1" attempt + shift + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2 + sleep $((attempt * 10)) + done +} +hdiutil_retry create create -srcfolder "$STAGE" -volname "EffectCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" +# The app inside the image must still pass --strict, so a makehybrid-style regression can't ship. +MOUNT="$WORK/dmg-check" +rm -rf "$MOUNT" +mkdir -p "$MOUNT" +hdiutil_retry attach attach -nobrowse -readonly -mountpoint "$MOUNT" "$DMG" >/dev/null +if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/EffectCraft.app"; then + hdiutil_retry detach detach "$MOUNT" >/dev/null || true + exit 1 +fi +hdiutil_retry detach detach "$MOUNT" >/dev/null if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" xcrun stapler staple "$DMG" From 7c1e8396031f2be5df4222c7af3ef580467463a4 Mon Sep 17 00:00:00 2001 From: iannitram <4624371+iannitram@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:45:17 -0600 Subject: [PATCH 039/185] CLI: an argument that is not Unicode is a usage error, not a panic effectcraft-cli read its command line with std::env::args, which panics on an argument that is not valid Unicode (a Latin-1 file name on Linux, for example). `effectcraft-cli $'\xff'` or `effectcraft-cli render-frame --out $'caf\xe9.png'` printed a panic from std's env.rs and exited 101. It now reads std::env::args_os and refuses such an argument as a usage error (exit 2) that names it, before anything runs, like an unknown option. A lossy copy is not used: every argument is a string here, and `caf\u{fffd}.png` would read or write another file than the one named. FilmCraft moved its CLI to args_os too (storytold/filmcraft#850). Tests: a unit test for the conversion and a process test that runs the CLI with a non-Unicode `--out` path (both unix-only). Before the fix the process test fails (exit 101, not 2). Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 24 +++++++++++++++++++++++- apps/effectcraft-cli/tests/cli.rs | 15 +++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index fe523807..56264e44 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -263,8 +263,18 @@ fn value_arg(s: &str) -> Value { serde_json::from_str(s).unwrap_or_else(|_| json!(s)) } +/// The command line as text. `std::env::args` panics on an argument that is not Unicode (a +/// Latin-1 file name on Linux). Every argument is used as a string, and a lossy copy would read or +/// write another file than the one named, so such an argument is a usage error naming it. +fn utf8_args(raw: impl IntoIterator) -> Result, String> { + raw.into_iter().map(|a| a.into_string().map_err(|a| format!("argument `{}` is not valid Unicode", a.to_string_lossy()))).collect() +} + fn main() { - let raw: Vec = std::env::args().skip(1).collect(); + let raw = match utf8_args(std::env::args_os().skip(1)) { + Ok(raw) => raw, + Err(e) => fail_usage(&e), + }; if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) { say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION")); return; @@ -1270,6 +1280,18 @@ mod tests { assert!(a.flag("--work-area") && a.flag("--json")); } + /// `std::env::args` panicked on an argument that is not Unicode; it is a usage error naming + /// it, and Unicode arguments pass through unchanged. + #[cfg(unix)] + #[test] + fn non_unicode_arguments_are_refused() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + let args = [OsString::from("render-frame"), OsString::from("--out"), OsString::from_vec(b"caf\xe9.png".to_vec())]; + assert_eq!(utf8_args(args).unwrap_err(), "argument `caf\u{fffd}.png` is not valid Unicode"); + assert_eq!(utf8_args([OsString::from("info"), OsString::from("caf\u{e9}.png")]).unwrap(), ["info", "caf\u{e9}.png"]); + } + /// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused. #[test] fn every_option_read_is_declared() { diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 62aefd5d..57c93438 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -160,6 +160,21 @@ fn unknown_options_are_usage_errors() { } } +/// An argument that is not Unicode (a Latin-1 file name on Linux) is a usage error (exit 2) +/// naming it, before anything runs: `std::env::args` panicked on one (exit 101). +#[cfg(unix)] +#[test] +fn non_unicode_arguments_are_usage_errors() { + use std::os::unix::ffi::OsStrExt; + let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png"); + let out = bin().args(["render-frame", "--out"]).arg(name).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "{err}"); + assert!(err.contains("argument `caf\u{fffd}.png` is not valid Unicode"), "{err}"); + assert!(!err.contains("panicked"), "{err}"); + assert!(out.stdout.is_empty(), "nothing ran"); +} + /// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command /// still does its work and exits 0, without a panic (#167). #[test] From da73df4b2eba41f2882a0a2eb283e6f770c6eb51 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:03:53 -0600 Subject: [PATCH 040/185] Script sockets: per-socket locks, a write deadline, and close interrupts (#1248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every socket operation locked one global table and held it through the blocking write_all/flush, and streams had no write timeout (accepted ones were explicitly blocking). A peer that stopped reading kept the script blocked for good, and every other socket call, close included, waited behind it. - The table maps handles to Arc, each with its own Mutex. The table is locked only to look a socket up, add or remove one, never during network I/O, so one stalled socket doesn't hold up the others. - `write` sets a write timeout from the socket's `timeout` (10 s by default, as reads) and reports "write timed out … the other side isn't reading" instead of blocking. Failures to set the read or write timeout, or to make an accepted stream blocking, are reported instead of ignored. - `close` removes the socket and shuts its stream down through a second handle, so a call blocked on it fails at once. Tests (socket.rs, native only): a 64 MB write to a loopback peer that never reads fails with "timed out" after a 1 s timeout; while one socket is blocked writing, another answers at once, and closing the blocked one returns at once and ends its write. Both fail on main (the write never gives up; the second socket waits on the table lock). Co-Authored-By: Claude Opus 5.5 --- crates/script/src/socket.rs | 168 +++++++++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 23 deletions(-) diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 6ac0d41d..4030332d 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -50,8 +50,8 @@ mod imp { mod imp { use std::collections::BTreeMap; use std::io::{ErrorKind, Read, Write}; - use std::net::{TcpListener, TcpStream, ToSocketAddrs}; - use std::sync::Mutex; + use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use super::*; @@ -61,9 +61,18 @@ mod imp { Listener(TcpListener), } + /// One open socket. Reading, writing and polling lock only it, never the whole table, so a + /// socket blocked on a slow peer doesn't hold up the others (#1248). + struct Entry { + sock: Mutex, + /// A second handle on a stream, so `close` can shut it down while another call is + /// blocked reading or writing it. + wake: Option, + } + struct Table { next: u32, - socks: BTreeMap, + socks: BTreeMap>, } fn table() -> &'static Mutex { @@ -72,13 +81,31 @@ mod imp { } fn add(s: Sock) -> u32 { + let wake = match &s { + Sock::Stream { s, .. } => s.try_clone().ok(), + Sock::Listener(_) => None, + }; + let entry = Arc::new(Entry { sock: Mutex::new(s), wake }); let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); let id = t.next; t.next += 1; - t.socks.insert(id, s); + t.socks.insert(id, entry); id } + /// The socket `id`, if open. The table is locked only for the lookup. + fn get(id: u32) -> Option> { + table().lock().unwrap_or_else(|e| e.into_inner()).socks.get(&id).cloned() + } + + /// Forget socket `id`; a call blocked on it fails at once. + fn close(id: u32) { + let entry = table().lock().unwrap_or_else(|e| e.into_inner()).socks.remove(&id); + if let Some(w) = entry.as_ref().and_then(|e| e.wake.as_ref()) { + let _ = w.shutdown(Shutdown::Both); + } + } + fn timeout(p: &J) -> Duration { Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) } @@ -98,8 +125,9 @@ mod imp { if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() } } - /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. - fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) { + /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. Fails only when + /// the read deadline can't be set. + fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) -> std::io::Result<()> { let end = Instant::now() + limit; let mut chunk = [0u8; 4096]; while !done(buf) && !*eof { @@ -107,7 +135,7 @@ mod imp { if left.is_zero() { break; } - let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1)))); + s.set_read_timeout(Some(left.max(Duration::from_millis(1))))?; match s.read(&mut chunk) { Ok(0) => *eof = true, Ok(n) => buf.extend_from_slice(&chunk[..n]), @@ -119,6 +147,7 @@ mod imp { Err(_) => *eof = true, } } + Ok(()) } pub(super) fn run(op: &str, id: u32, p: &J) -> J { @@ -156,29 +185,27 @@ mod imp { Err(e) => err(format!("cannot listen on port {port}: {e}")), } } + "close" => { + close(id); + json!({}) + } _ => { - let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); - if op == "close" { - t.socks.remove(&id); - return json!({}); - } - let Some(sock) = t.socks.get_mut(&id) else { + let Some(entry) = get(id) else { return match op { "connected" => json!({"value": false}), "eof" => json!({"value": true}), _ => err("the socket is not open"), }; }; - match (op, sock) { + let mut sock = entry.sock.lock().unwrap_or_else(|e| e.into_inner()); + match (op, &mut *sock) { ("poll", Sock::Listener(l)) => match l.accept() { Ok((s, peer)) => { - let _ = s.set_nonblocking(false); + if let Err(e) = s.set_nonblocking(false) { + return err(format!("cannot use the connection from {peer}: {e}")); + } let host = peer.to_string(); - let new = Sock::Stream { s, buf: vec![], eof: false }; - let nid = t.next; - t.next += 1; - t.socks.insert(nid, new); - json!({"id": nid, "host": host}) + json!({"id": add(Sock::Stream { s, buf: vec![], eof: false }), "host": host}) } Err(_) => json!({"id": null}), }, @@ -202,19 +229,25 @@ mod imp { let count = p.get("count").and_then(J::as_i64).unwrap_or(-1); let limit = timeout(p); if count < 0 { - fill(s, buf, eof, limit, |_| false); + if let Err(e) = fill(s, buf, eof, limit, |_| false) { + return err(format!("cannot set the read timeout: {e}")); + } let all = std::mem::take(buf); json!({"data": decode(&all, utf8)}) } else { let n = count as usize; - fill(s, buf, eof, limit, |b| b.len() >= n); + if let Err(e) = fill(s, buf, eof, limit, |b| b.len() >= n) { + return err(format!("cannot set the read timeout: {e}")); + } let take = n.min(buf.len()); let out: Vec = buf.drain(..take).collect(); json!({"data": decode(&out, utf8)}) } } ("readln", Sock::Stream { s, buf, eof }) => { - fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')); + if let Err(e) = fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')) { + return err(format!("cannot set the read timeout: {e}")); + } let line: Vec = match buf.iter().position(|&c| c == b'\n') { Some(i) => { let mut l: Vec = buf.drain(..=i).collect(); @@ -229,9 +262,19 @@ mod imp { json!({"data": decode(&line, utf8)}) } ("write", Sock::Stream { s, .. }) => { + // A peer that stops reading fills the buffers: give up after `timeout` + // instead of blocking the script for good (#1248). + let limit = timeout(p).max(Duration::from_millis(1)); + if let Err(e) = s.set_write_timeout(Some(limit)) { + return err(format!("cannot set the write timeout: {e}")); + } let data = p.get("data").and_then(J::as_str).unwrap_or(""); match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { Ok(()) => json!({"ok": true}), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!( + "write timed out after {} s: the other side isn't reading (some of the data may have been sent)", + limit.as_secs_f64() + )), Err(e) => err(e), } } @@ -242,3 +285,82 @@ mod imp { } } } + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + use serde_json::{Value as J, json}; + + use super::imp::run; + + /// A loopback peer that accepts one connection and never reads from it; returns its + /// `host:port` and the accepted stream's keeper (drop it to let the connection go). + fn stalled_peer() -> (String, mpsc::Sender<()>) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let host = l.local_addr().unwrap().to_string(); + let (stop, wait) = mpsc::channel::<()>(); + std::thread::spawn(move || { + let (_s, _): (TcpStream, _) = l.accept().unwrap(); + let _ = wait.recv_timeout(Duration::from_secs(120)); + }); + (host, stop) + } + + fn open(host: &str) -> u32 { + let r = run("open", 0, &json!({"host": host, "timeout": 5})); + r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32 + } + + /// More than the loopback buffers hold, so a peer that doesn't read stops the write. + fn big() -> String { + "x".repeat(64 * 1024 * 1024) + } + + /// Run `f` on a thread; its result, or `None` if it takes longer than `limit`. + fn within(limit: Duration, f: impl FnOnce() -> T + Send + 'static) -> Option { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(limit).ok() + } + + #[test] + fn a_write_to_a_peer_that_stops_reading_fails_after_the_timeout() { + // #1248: it used to block for good. + let (host, _keep) = stalled_peer(); + let id = open(&host); + let data = big(); + let t0 = Instant::now(); + let r = within(Duration::from_secs(20), move || run("write", id, &json!({"data": data, "timeout": 1}))).expect("the write gave up"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("timed out")), "{r}"); + assert!(t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed()); + run("close", id, &J::Null); + } + + #[test] + fn a_blocked_socket_doesnt_hold_up_the_others_and_close_interrupts_it() { + let (host_a, _keep_a) = stalled_peer(); + let (host_b, _keep_b) = stalled_peer(); + let (a, b) = (open(&host_a), open(&host_b)); + // A write on A that would wait a minute. + let data = big(); + let (tx, blocked) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(run("write", a, &json!({"data": data, "timeout": 60}))); + }); + std::thread::sleep(Duration::from_millis(500)); + // Socket B answers meanwhile (the table isn't locked by A's write). + let r = within(Duration::from_secs(3), move || run("connected", b, &json!({}))).expect("socket B answered while A was blocked"); + assert_eq!(r["value"], true, "{r}"); + // Closing A returns at once and ends A's write. + within(Duration::from_secs(3), move || run("close", a, &J::Null)).expect("close returned while A was blocked"); + let r = blocked.recv_timeout(Duration::from_secs(5)).expect("A's write ended when A was closed"); + assert!(r.get("error").is_some(), "{r}"); + run("close", b, &J::Null); + } +} From be3261419b66a49359577d3173abb2c7ee6a7768 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Wed, 14 Oct 2026 00:46:05 +0200 Subject: [PATCH 041/185] =?UTF-8?q?Viewer:=20Composition=20Settings?= =?UTF-8?q?=E2=80=A6=20in=20the=20composition's=20right-click=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/viewer.rs | 7 +++++++ crates/ui-egui/tests/ui_blank_context_menus.rs | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..c5251a27 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1281,6 +1281,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { context_actions.push(("comp.copyFrame".to_owned(), serde_json::json!({"comp": cid.0}))); ui.close(); } + // The comp this viewer shows, which is not always the active one. + let settings = ui.button(crate::i18n::tr("Composition Settings…")); + app.auto.add("viewer.context.settings", settings.rect, "Composition Settings…"); + if settings.clicked() { + context_actions.push(("comp.settings".to_owned(), serde_json::json!({"comp": cid.0}))); + ui.close(); + } }); for (id, params) in context_actions { if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index 96789fe7..3c03149a 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -87,6 +87,20 @@ fn timeline_blank_menu_opens_solid_settings() { assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); } +/// Right-clicking the composition in the viewer offers Composition Settings…, which opens on the +/// comp the viewer shows. +#[test] +fn viewer_menu_opens_composition_settings() { + let mut h = harness(); + let comp = h.state().session.active_comp_id().unwrap(); + let p = rect(&h, "viewer.comp").center(); + right_click(&mut h, p); + h.get_by_label("Composition Settings…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert_eq!(h.state().session.active_comp_id(), Some(comp)); +} + #[test] fn project_item_and_timeline_layer_keep_their_own_context_menus() { let mut h = harness(); From dd5afffee3c485957ebe393b9d36cf1382d3f3b5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 00:55:12 +0200 Subject: [PATCH 042/185] Scripting: test that Layer.property() resolves AE's top-level shortcuts (#1348) --- crates/script/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 2c821e6a..9a3d9fe5 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1375,3 +1375,42 @@ fn scripted_levels_alpha_controls_resolve_from_the_effect() { ); assert_eq!(o.result, json!([0.5, 0.5])); } + +/// After Effects resolves the Transform properties and Source Text from the layer itself, without +/// naming their group: `layer.property("Position")` (#1348). +#[test] +fn layer_property_resolves_transform_and_source_text_shortcuts() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("x", 100, 100, 1, 5, 30); + var t = c.layers.addText("hi"); + var s = c.layers.addSolid([1, 0, 0], "s", 100, 100, 1); + var listed = [t.property("Source Text"), s.property("Position"), s.property("Opacity"), s.property("Scale")].join(", "); + s.property("Position").setValue([30, 40]); + s.property("Opacity").setValue(25); + t.property("Source Text").setValue("bye"); + s.threeDLayer = true; + [listed, s.property("Position").value[0], s.property("Opacity").value, t.property("Source Text").value.text, + s.property("ADBE Position").matchName, s("Anchor Point").matchName, s.property("Rotation").matchName, + s.property("X Rotation").matchName, s.property("Orientation").matchName] + "#, + ); + assert_eq!( + o.result, + json!([ + "[object Property], [object Property], [object Property], [object Property]", + 30, + 25, + "bye", + "ADBE Position", + "ADBE Anchor Point", + "ADBE Rotate Z", + "ADBE Rotate X", + "ADBE Orientation" + ]) + ); + let (_, c) = comp_named(&s, "x"); + assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0)); +} From 79ebd019352508b0b461b557dc5110d328f61839 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:59:08 -0600 Subject: [PATCH 043/185] svg: bound expansion by an element budget and skip reference cycles (#1236) Depth-limited expansion still let a few kilobytes of branching references expand to 2^N elements. The parser now counts every element it converts, copies included, against a budget of max(500 000, twice the file's own elements) and fails the import with Error::Xml once it is spent. Targets currently being expanded are tracked, so mutual references stop at the first repeat instead of nesting to the depth limit. Fixes #1236 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/svg/src/lib.rs | 26 +++++++++++++++--- crates/svg/src/tests.rs | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 7310814b..b5ddd98e 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -486,6 +486,12 @@ struct Parser<'a, 'i> { vw: f64, vh: f64, skipped: Vec, + /// Elements converted so far, `` copies included, and how many may be (#1236). + work: usize, + budget: usize, + /// The `` targets being expanded, outermost first: a reference back into one is a + /// cycle and is skipped. + using: Vec, } fn is_svg_ns(n: &roxmltree::Node) -> bool { @@ -748,9 +754,12 @@ impl<'a, 'i> Parser<'a, 'i> { /// Convert an element (and its subtree) into nodes appended to `out`. fn node(&mut self, n: roxmltree::Node<'a, 'i>, parent: &Style, out: &mut Vec, depth: usize) { - if depth > 64 || !is_svg_ns(&n) { + if depth > 64 || !is_svg_ns(&n) || self.work > self.budget { return; } + // Every element converted counts, each `` copy again: a few references to groups + // that reference each other several times can multiply into billions (#1236). + self.work += 1; let tag = n.tag_name().name(); match tag { "defs" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "symbol" | "clipPath" | "mask" | "pattern" | "marker" @@ -816,9 +825,10 @@ impl<'a, 'i> Parser<'a, 'i> { } "use" => { let Some(target) = self.href(n) else { return }; - if target == n || n.ancestors().any(|a| a == target) { + if target == n || n.ancestors().any(|a| a == target) || self.using.contains(&target.id()) { return; } + self.using.push(target.id()); let x = self.attr_len(n, "x", 'x', &st); let y = self.attr_len(n, "y", 'y', &st); transform *= Affine::translate((x, y)); @@ -847,6 +857,7 @@ impl<'a, 'i> Parser<'a, 'i> { } else { self.node(target, &st, &mut children, depth + 1); } + self.using.pop(); out.push(Node::Group(Group { name: Self::element_name(n), transform, @@ -1048,6 +1059,9 @@ pub fn parse_transform(s: &str) -> Affine { /// 2 MiB thread's stack (aborting the app) at a few thousand levels; real documents stay far /// below this. const MAX_XML_DEPTH: usize = 256; +/// The most elements an import converts, `` copies included, unless the file itself has +/// more than half as many (#1236). +const MAX_EXPANDED_ELEMENTS: usize = 500_000; /// The deepest element nesting of `text` (a quick scan that skips comments, CDATA, processing /// instructions, declarations and quoted attribute values). @@ -1121,7 +1135,9 @@ pub fn parse(bytes: &[u8]) -> Result { } } let vb = viewbox(root); - let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![] }; + // Room for generous reuse, and always for every element of the file itself. + let budget = MAX_EXPANDED_ELEMENTS.max(root.descendants().filter(|n| n.is_element()).count().saturating_mul(2)); + let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![], work: 0, budget, using: vec![] }; let st = Style::default(); let pct_or = |name: &str, axis: char, def: f64| -> f64 { match root.attribute(name) { @@ -1157,6 +1173,10 @@ pub fn parse(bytes: &[u8]) -> Result { for c in root.children() { p.node(c, &rst, &mut children, 1); } + if p.work > p.budget { + // Never a silently partial drawing. + return Err(Error::Xml(format!("its references expand to more than {} elements", p.budget))); + } let transform = viewbox_transform(root, dw, dh); Ok(Doc { width: dw, diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index ed1ea873..93db715c 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -217,3 +217,63 @@ fn deep_nesting_errors_instead_of_overflowing_the_stack() { assert_eq!(crate::xml_depth(&s), 2); assert!(crate::parse(s.as_bytes()).is_ok()); } + +/// How deep `g`'s groups nest, and how many shapes it holds. +fn depth_and_shapes(g: &Group) -> (usize, usize) { + let mut depth = 0; + let mut shapes = 0; + for c in &g.children { + match c { + Node::Group(sub) => { + let (d, s) = depth_and_shapes(sub); + depth = depth.max(d + 1); + shapes += s; + } + Node::Shape(_) => shapes += 1, + Node::Image(_) => {} + } + } + (depth, shapes) +} + +#[test] +fn branching_use_references_stop_at_the_expansion_budget() { + // Each group uses the one before twice: 30 levels are 2^30 copies of one rectangle, from a + // file of a few kilobytes (#1236). + let mut defs = String::from(r#""#); + for i in 1..=30 { + defs += &format!(r##""##, p = i - 1); + } + let svg = format!(r##"{defs}"##); + let t0 = std::time::Instant::now(); + let e = parse(svg.as_bytes()).unwrap_err(); + assert!(e.to_string().contains("expand to more than"), "{e}"); + assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed()); + // Below the budget the same pattern imports whole: 2^10 rectangles. + let small = svg.replace(r##""##, r##""##); + let doc = parse(small.as_bytes()).unwrap(); + assert_eq!(depth_and_shapes(&doc.root).1, 1024); +} + +#[test] +fn mutually_referencing_uses_are_cut_at_the_first_repeat() { + // A uses B and B uses A, neither inside the other: once each, not down to the depth limit. + let svg = r##" + + + "##; + let doc = parse(svg.as_bytes()).unwrap(); + let (depth, shapes) = depth_and_shapes(&doc.root); + assert_eq!(shapes, 2, "a's and b's rectangle, once each"); + assert!(depth <= 6, "{depth}"); +} + +#[test] +fn a_symbol_used_a_thousand_times_still_imports() { + let uses: String = (0..1000).map(|i| format!(r##""##)).collect(); + let svg = format!( + r##"{uses}"## + ); + let doc = parse(svg.as_bytes()).unwrap(); + assert_eq!(depth_and_shapes(&doc.root).1, 3000); +} From 9f02bd2a722f6d2b8328aa694b247a8fdcc12a9f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Wed, 14 Oct 2026 00:59:11 +0200 Subject: [PATCH 044/185] #1368: dragging an effect lights up the layer it will land on Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/timeline.rs | 12 +++++- .../ui-egui/tests/ui_effect_drop_highlight.rs | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/ui_effect_drop_highlight.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..c63df878 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3330,8 +3330,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { { let released = ctx.input(|i| i.pointer.any_released()); if let crate::panels::DragPayload::Effect(e) = payload.as_ref() { - p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); + // The layer the effect would land on (its row and its open properties) is the one + // that lights up, not the whole timeline. + if let Some(l) = target + && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b)) + { + let lit = span.intersect(rows_rect); + let rp = p.with_clip_rect(rows_rect); + rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18)); + rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + app.auto.add("timeline.dropTarget", lit, "Drop target layer"); + } if released && let Some(l) = target { actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))); } diff --git a/crates/ui-egui/tests/ui_effect_drop_highlight.rs b/crates/ui-egui/tests/ui_effect_drop_highlight.rs new file mode 100644 index 00000000..6c0ede47 --- /dev/null +++ b/crates/ui-egui/tests/ui_effect_drop_highlight.rs @@ -0,0 +1,40 @@ +//! Dragging an effect over the Timeline lights up the layer it would land on, not the whole panel. +use effectcraft_engine::Session; +use effectcraft_ui_egui::{EffectcraftApp, panels::DragPayload}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn drag_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Effect("CC Bend It".into())); + h.run_steps(2); +} + +#[test] +fn effect_drag_highlights_the_layer_under_the_pointer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); + let a = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().auto.find("timeline.dropTarget").is_none(), "nothing is lit without a drag"); + let (row_a, row_b) = (rect(&h, &format!("timeline.layer.{a}.row")), rect(&h, &format!("timeline.layer.{b}.row"))); + let panel = rect(&h, "timeline.layerMarquee"); + + drag_to(&mut h, row_b.center()); + let lit = rect(&h, "timeline.dropTarget"); + assert!(lit.contains(row_b.center()) && !lit.contains(row_a.center()), "lit {lit:?} for row {row_b:?}"); + assert!(lit.height() < panel.height(), "only the layer is lit, not the whole timeline"); + + drag_to(&mut h, row_a.center()); + let lit = rect(&h, "timeline.dropTarget"); + assert!(lit.contains(row_a.center()) && !lit.contains(row_b.center()), "the highlight follows the pointer: {lit:?}"); +} From 3d45046132aa841fc768fde3d8f7b59dc4e23f7f Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 17:35:25 -0600 Subject: [PATCH 045/185] model: validate glTF accessor spans before allocating, with checked arithmetic (#1235) Accessor counts sized the f32/u32 output before the buffer view was looked at, and offsets, strides and count * components used unchecked arithmetic, so a few hundred bytes of JSON could request a multi-gigabyte allocation (aborting the process) or overflow. - Doc::layout checks count * components, adds the view and accessor offsets, rejects a byteStride smaller than an element, and checks that the last element ends inside the buffer, all with checked arithmetic, before floats() or ints() allocate anything. - Accessors without a buffer view (all zeros) are capped at 2^24 components. - idx() and node index lists use usize::try_from instead of 'as usize'; an image bufferView's end and a GLB chunk's end are computed with checked_add. Fixes #1235 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/model/src/gltf.rs | 87 +++++++++++++++++++++++++++++---------- crates/model/src/tests.rs | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index 20578d4f..e17d014f 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -39,13 +39,15 @@ pub fn split_glb(b: &[u8]) -> Result<(&[u8], Option<&[u8]>)> { while o + 8 <= total { let len = u32_at(o).unwrap_or(0) as usize; let ty = u32_at(o + 4).unwrap_or(0); - let data = b.get(o + 8..o + 8 + len).ok_or_else(|| perr("GLB chunk runs past the end of the file"))?; + let past_end = || perr("GLB chunk runs past the end of the file"); + let end = (o + 8).checked_add(len).ok_or_else(past_end)?; + let data = b.get(o + 8..end).ok_or_else(past_end)?; match ty { CHUNK_JSON if json.is_none() => json = Some(data), CHUNK_BIN if bin.is_none() => bin = Some(data), _ => {} } - o += 8 + len; + o = end; } Ok((json.ok_or_else(|| perr("GLB without a JSON chunk"))?, bin)) } @@ -115,8 +117,25 @@ struct Doc<'a> { fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] { v.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) } +/// A non-negative integer field; one too large for `usize` counts as absent (#1235). fn idx(v: &Value, k: &str) -> Option { - v.get(k).and_then(Value::as_u64).map(|x| x as usize) + v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()) +} +/// Node indices in an array field (out-of-range ones are dropped). +fn indices(v: &Value, k: &str) -> Vec { + arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).collect() +} + +/// The most components an accessor without a buffer view (all zeros) may have: nothing in the +/// file backs its count, so it is capped instead of checked against a buffer (#1235). +const MAX_ZERO_COMPONENTS: usize = 1 << 24; + +/// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride` +/// apart from `base`, all checked to be inside `buf`. +struct Span<'b> { + buf: &'b [u8], + base: usize, + stride: usize, } fn num(v: &Value, k: &str, d: f64) -> f64 { v.get(k).and_then(Value::as_f64).unwrap_or(d) @@ -132,6 +151,39 @@ fn nums(v: &Value, k: &str, d: [f64; N]) -> [f64; N] { } impl Doc<'_> { + /// The accessor's element count and total components (`count * n`), checked; and its span in + /// the buffer, validated before anything is allocated from the count. `None` for an + /// accessor without a buffer view, whose components are all zero. + fn layout(&self, acc: &Value, n: usize, csize: usize) -> Result<(usize, usize, Option>)> { + let count = match acc.get("count") { + None => 0, + Some(c) => c.as_u64().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| perr("accessor count is not a valid size"))?, + }; + let total = count.checked_mul(n).ok_or_else(|| perr("accessor count is too large"))?; + let Some(bv) = idx(acc, "bufferView") else { + if total > MAX_ZERO_COMPONENTS { + return Err(perr(format!("accessor without a bufferView has {total} components (at most {MAX_ZERO_COMPONENTS})"))); + } + return Ok((count, total, None)); + }; + let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; + let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; + let past = || perr("accessor reads past its buffer"); + let base = idx(view, "byteOffset").unwrap_or(0).checked_add(idx(acc, "byteOffset").unwrap_or(0)).ok_or_else(past)?; + let elem = csize * n; + let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); + if stride < elem { + return Err(perr(format!("bufferView byteStride {stride} is smaller than its {elem}-byte elements"))); + } + if count > 0 { + let end = (count - 1).checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(elem)).ok_or_else(past)?; + if end > buf.len() { + return Err(past()); + } + } + Ok((count, total, Some(Span { buf, base, stride }))) + } + /// An accessor as `f32` rows of `n` components (normalised integers mapped to 0..1 / -1..1). fn floats(&self, a: usize) -> Result<(Vec, usize)> { let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?; @@ -145,7 +197,6 @@ impl Doc<'_> { "MAT4" => 16, t => return Err(perr(format!("accessor type `{t}`"))), }; - let count = idx(acc, "count").unwrap_or(0); let ct = idx(acc, "componentType").unwrap_or(5126); let norm = acc.get("normalized").and_then(Value::as_bool).unwrap_or(false); let csize = match ct { @@ -154,17 +205,13 @@ impl Doc<'_> { 5125 | 5126 => 4, _ => return Err(perr(format!("componentType {ct}"))), }; - let mut out = vec![0.0f32; count * n]; - let Some(bv) = idx(acc, "bufferView") else { + let (count, total, span) = self.layout(acc, n, csize)?; + let mut out = vec![0.0f32; total]; + let Some(Span { buf, base, stride }) = span else { // No buffer view: all zeros (sparse substitution is not supported). return Ok((out, n)); }; - let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; - let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; - let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0); - let elem = csize * n; // Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare). - let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); for i in 0..count { for c in 0..n { let o = base + i * stride + c * csize; @@ -207,18 +254,14 @@ impl Doc<'_> { "VEC2" => 2, _ => 1, }; - let count = idx(acc, "count").unwrap_or(0); let csize = match ct { 5120 | 5121 => 1, 5122 | 5123 => 2, _ => 4, }; - let Some(bv) = idx(acc, "bufferView") else { return Ok(vec![0; count * n]) }; - let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; - let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; - let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0); - let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(csize * n); - let mut out = Vec::with_capacity(count * n); + let (count, total, span) = self.layout(acc, n, csize)?; + let Some(Span { buf, base, stride }) = span else { return Ok(vec![0; total]) }; + let mut out = Vec::with_capacity(total); for i in 0..count { for c in 0..n { let o = base + i * stride + c * csize; @@ -268,7 +311,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< arr(&j, "bufferViews").get(bv).and_then(|v| { let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?; let o = idx(v, "byteOffset").unwrap_or(0); - b.get(o..o + idx(v, "byteLength").unwrap_or(0)).map(<[u8]>::to_vec) + b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec) }) } else { None @@ -366,13 +409,13 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< skin: idx(n, "skin"), camera: idx(n, "camera"), light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")), - children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(), + children: indices(n, "children"), }); } let scene = idx(&j, "scene").unwrap_or(0); let roots: Vec = match arr(&j, "scenes").get(scene) { - Some(s) => arr(s, "nodes").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(), + Some(s) => indices(s, "nodes"), None => { // No scene: every node that is nobody's child. let mut child = vec![false; nodes.len()]; @@ -389,7 +432,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< let mut skins = vec![]; for s in arr(&j, "skins") { - let joints: Vec = arr(s, "joints").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(); + let joints: Vec = indices(s, "joints"); let inverse_bind = match idx(s, "inverseBindMatrices") { Some(a) => { let (f, _) = d.floats(a)?; diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 9206ef83..7c98c1ae 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -492,3 +492,75 @@ fn stl_hostile_input_is_an_error() { let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap(); assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]); } + +/// A one-triangle glTF whose POSITION is accessor 0, over a buffer of `bytes` zero bytes. +fn gltf_doc(accessors: &str, views: &str, bytes: usize) -> String { + let b64 = "A".repeat(bytes / 3 * 4) + ["", "AA==", "AAA="][bytes % 3]; + format!( + r#"{{"asset": {{"version": "2.0"}}, "scenes": [{{"nodes": [0]}}], "nodes": [{{"mesh": 0}}], + "meshes": [{{"primitives": [{{"attributes": {{"POSITION": 0}}}}]}}], + "buffers": [{{"byteLength": {bytes}, "uri": "data:application/octet-stream;base64,{b64}"}}], + "bufferViews": [{views}], "accessors": [{accessors}]}}"# + ) +} + +#[test] +fn gltf_accessor_counts_are_checked_against_the_buffer_before_allocating() { + // Untrusted counts, offsets and strides must fail the import, not allocate from the count or + // overflow (#1235). + let view = r#"{"buffer": 0, "byteLength": 36}"#; + let cases = [ + // A count far beyond the 36-byte buffer. + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 4000000000}"#, view, "past its buffer"), + // count * components overflows. + (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 9223372036854775807}"#, view, "too large"), + // Offsets that overflow when added. + ( + r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "byteOffset": 16}"#, + r#"{"buffer": 0, "byteOffset": 18446744073709551610}"#, + "past its buffer", + ), + // A stride that overflows with the count. + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3000000000}"#, r#"{"buffer": 0, "byteStride": 252}"#, "past its buffer"), + // A stride smaller than an element would re-read the same bytes for a huge count. + (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 30}"#, r#"{"buffer": 0, "byteStride": 1}"#, "smaller than"), + // No buffer view: nothing backs the count, so it is capped. + (r#"{"componentType": 5126, "type": "VEC3", "count": 1000000000}"#, view, "without a bufferView"), + ]; + let t0 = std::time::Instant::now(); + for (acc, v, want) in cases { + let doc = gltf_doc(acc, v, 36); + let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("{acc} imported")); + assert!(e.to_string().contains(want), "{acc}: {e}"); + } + assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed()); +} + +#[test] +fn gltf_integer_accessors_are_checked_too() { + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteLength": 36}"#, 36) + .replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1"#); + for idx in [ + r#"{"bufferView": 0, "componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, + r#"{"componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, + r#"{"bufferView": 0, "componentType": 5125, "type": "VEC4", "count": 9223372036854775807}"#, + ] { + let doc = doc.replace(r#""count": 3}]"#, &format!(r#""count": 3}}, {idx}]"#)); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{idx}"); + } +} + +#[test] +fn gltf_strided_and_buffer_less_accessors_still_import() { + // Interleaved positions (stride 24) ending exactly at the buffer's end, and a small all-zero + // accessor without a view. + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 60); + let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives[0].positions.len(), 3); + let doc = gltf_doc(r#"{"componentType": 5126, "type": "VEC3", "count": 6}"#, r#"{"buffer": 0}"#, 3); + let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives[0].positions.len(), 6); + // One byte short of the third element is an error. + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 59); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err()); +} From 4b475f57815e3743c0a44db95d7410fe4150ce44 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 17:44:27 -0600 Subject: [PATCH 046/185] model: budget the memory an FBX import keeps, and share instanced meshes (#1234) The 1 GiB array limit counted wire bytes, but arrays are kept as f64/i64, so boolean arrays grew 8x and 32-bit arrays 2x past it, and nothing bounded the total across arrays. Every Model linked to a Geometry also built its own copy of the mesh. - A per-import Budget (2 GiB) is charged 8 bytes per array element before the array is decompressed or widened, ASCII files are charged for their parsed values, and each built mesh is charged for its vertex and index data. Going past it fails the import with a clear error instead of an allocation abort. parse_within/read_within take a lower limit for tests. - Models instancing the same geometry with the same materials share one mesh; a different material mapping still gets its own. Fixes #1234 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/model/src/fbx.rs | 102 ++++++++++++++++++++++++++++++---- crates/model/src/fbx_tests.rs | 84 ++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/crates/model/src/fbx.rs b/crates/model/src/fbx.rs index e2b9e1bd..8a3fd9f4 100644 --- a/crates/model/src/fbx.rs +++ b/crates/model/src/fbx.rs @@ -32,6 +32,57 @@ pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0"; const MAX_DEPTH: usize = 64; /// Largest decoded array accepted (bytes). const MAX_ARRAY_BYTES: usize = 1 << 30; +/// Most memory one import may keep: parsed arrays as stored (8 bytes per element, whatever the +/// wire type) plus the meshes built from them (#1234). +const MAX_IMPORT_BYTES: usize = 1 << 31; + +/// What is left of an import's memory budget. +struct Budget(std::cell::Cell); + +impl Budget { + fn new(bytes: usize) -> Budget { + Budget(std::cell::Cell::new(bytes)) + } + /// Take `bytes` (None: a size that overflowed) from the budget, or fail the import before + /// anything that large is allocated. + fn take(&self, bytes: Option, what: &str) -> Result<()> { + let left = self.0.get(); + match bytes.filter(|&b| b <= left) { + Some(b) => { + self.0.set(left - b); + Ok(()) + } + None => Err(perr(format!("FBX: {what} would take the import past its {} MiB memory limit", MAX_IMPORT_BYTES >> 20))), + } + } +} + +/// Bytes a mesh keeps. +fn mesh_bytes(m: &Mesh) -> Option { + m.primitives.iter().try_fold(0usize, |acc, p| { + let n = p.positions.len().checked_mul(12)?.checked_add(p.normals.len().checked_mul(12)?)?.checked_add(p.uvs.len().checked_mul(8)?)?; + let n = n.checked_add(p.tangents.len().checked_mul(16)?)?.checked_add(p.joints.len().checked_mul(8)?)?.checked_add(p.weights.len().checked_mul(16)?)?; + acc.checked_add(n)?.checked_add(p.indices.len().checked_mul(4)?) + }) +} + +/// Bytes the values under `nodes` keep. Used for ASCII files, which hold array elements as +/// one `Prop` each and are parsed before they can be counted (the text itself bounds them). +fn value_bytes(nodes: &[FbxNode]) -> Option { + nodes.iter().try_fold(0usize, |acc, n| { + let own = n.props.iter().try_fold(0usize, |a, p| { + let extra = match p { + Prop::Ints(v) => v.len().checked_mul(8)?, + Prop::Floats(v) => v.len().checked_mul(8)?, + Prop::Str(v) => v.len(), + Prop::Bytes(v) => v.len(), + Prop::Int(_) | Prop::Float(_) => 0, + }; + a.checked_add(std::mem::size_of::())?.checked_add(extra) + })?; + acc.checked_add(own)?.checked_add(value_bytes(&n.children)?) + }) +} /// One property value of an FBX node. #[derive(Clone, Debug, PartialEq)] @@ -126,7 +177,17 @@ impl FbxNode { /// Read an FBX file (binary or ASCII) into its top-level nodes and the file version /// (e.g. 7400). pub fn read(bytes: &[u8]) -> Result<(u32, Vec)> { - if bytes.starts_with(BINARY_MAGIC) { read_binary(bytes) } else { read_ascii(&String::from_utf8_lossy(bytes)) } + read_within(bytes, &Budget::new(MAX_IMPORT_BYTES)) +} + +fn read_within(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { + if bytes.starts_with(BINARY_MAGIC) { + read_binary(bytes, budget) + } else { + let (version, nodes) = read_ascii(&String::from_utf8_lossy(bytes))?; + budget.take(value_bytes(&nodes), "its values")?; + Ok((version, nodes)) + } } // --------------------------------------------------------------------------------------------- @@ -136,6 +197,7 @@ struct Bin<'a> { d: &'a [u8], /// 64-bit record offsets (version 7500 and later). wide: bool, + budget: &'a Budget, } impl Bin<'_> { @@ -207,7 +269,7 @@ impl Bin<'_> { } fn prop(&self, pos: &mut usize, pend: usize) -> Result { - let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide }; + let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide, budget: self.budget }; let t = sub.arr::<1>(pos)?[0]; Ok(match t { b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64), @@ -234,6 +296,9 @@ impl Bin<'_> { _ => 1, }; let want = len.checked_mul(size).filter(|&n| n <= MAX_ARRAY_BYTES).ok_or_else(|| perr("FBX: array too large"))?; + // Elements are kept as f64/i64 (booleans and 32-bit values widen), so the budget + // is charged for what is kept, not for the wire bytes. + sub.budget.take(len.checked_mul(8), "an array")?; let data = sub.take(pos, clen)?; let raw = match enc { 0 => data.to_vec(), @@ -263,10 +328,10 @@ fn object_name(b: &[u8]) -> String { } } -fn read_binary(bytes: &[u8]) -> Result<(u32, Vec)> { +fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { let mut pos = BINARY_MAGIC.len(); - let version = Bin { d: bytes, wide: false }.u32(&mut pos)?; - let bin = Bin { d: bytes, wide: version >= 7500 }; + let version = Bin { d: bytes, wide: false, budget }.u32(&mut pos)?; + let bin = Bin { d: bytes, wide: version >= 7500, budget }; let mut nodes = vec![]; while pos < bytes.len() { match bin.record(&mut pos, 0) { @@ -728,7 +793,13 @@ fn short_name(full: &str) -> &str { /// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it. pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { - let (version, doc) = read(bytes)?; + parse_within(bytes, resolve, MAX_IMPORT_BYTES) +} + +/// [`parse`] keeping at most `limit` bytes of arrays and meshes. +fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: usize) -> Result { + let budget = Budget::new(limit); + let (version, doc) = read_within(bytes, &budget)?; if version != 0 && version < 7000 { return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100))); } @@ -835,19 +906,28 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< Some(materials.len() - 1) }; - // One mesh per Model with Mesh geometry (material slots are per Model). + // One mesh per Model with Mesh geometry (material slots are per Model). Models instancing the + // same geometry with the same materials share one mesh rather than each building a copy. let mut meshes: Vec = vec![]; + let mut mesh_of: HashMap<(i64, Vec), usize> = HashMap::new(); for &id in &model_ids { let Some(&ni) = node_of.get(&id) else { continue }; let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue }; let Some(geom) = by_id.get(&gid) else { continue }; let mats: Vec = links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect(); - let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default(); - let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; - meshes.push(mesh); - let mesh_index = meshes.len() - 1; + let mesh_index = match mesh_of.get(&(gid, mats.clone())) { + Some(&i) => i, + None => { + let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); + let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; + budget.take(mesh_bytes(&mesh), &format!("mesh `{name}`"))?; + meshes.push(mesh); + mesh_of.insert((gid, mats), meshes.len() - 1); + meshes.len() - 1 + } + }; let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o))); match geo { Some(matrix) => { diff --git a/crates/model/src/fbx_tests.rs b/crates/model/src/fbx_tests.rs index 3cd8ba5a..1ba1978b 100644 --- a/crates/model/src/fbx_tests.rs +++ b/crates/model/src/fbx_tests.rs @@ -325,3 +325,87 @@ fn hostile_fbx_files_fail_cleanly() { assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_)))); assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err()); } + +/// A version-7400 binary FBX holding one top-level node `A` with uncompressed arrays of type +/// `t` (`b'b'`, `b'i'`, `b'f'`, `b'd'`, `b'l'`) and `len` elements each. +fn binary_arrays(arrays: &[(u8, usize)]) -> Vec { + let mut props = vec![]; + for &(t, len) in arrays { + let size = match t { + b'd' | b'l' => 8, + b'f' | b'i' => 4, + _ => 1, + }; + props.push(t); + props.extend((len as u32).to_le_bytes()); + props.extend(0u32.to_le_bytes()); + props.extend(((len * size) as u32).to_le_bytes()); + props.extend(std::iter::repeat_n(1u8, len * size)); + } + let mut out = BINARY_MAGIC.to_vec(); + out.extend(7400u32.to_le_bytes()); + let end = out.len() + 13 + 1 + props.len(); + out.extend((end as u32).to_le_bytes()); + out.extend((arrays.len() as u32).to_le_bytes()); + out.extend((props.len() as u32).to_le_bytes()); + out.push(1); + out.push(b'A'); + out.extend(props); + out +} + +#[test] +fn fbx_arrays_are_charged_for_the_memory_they_keep() { + // A boolean array is one byte per element on the wire but eight once kept as `Prop::Ints`: + // the budget counts the eight (#1234). + let n = 10_000; + let bin = binary_arrays(&[(b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 8)).is_ok()); + let e = read_within(&bin, &Budget::new(n * 8 - 1)).unwrap_err(); + assert!(e.to_string().contains("memory limit"), "{e}"); + // 32-bit floats widen to f64 the same way. + assert!(read_within(&binary_arrays(&[(b'f', n)]), &Budget::new(n * 4)).is_err()); + // Several arrays each within the budget but not together. + let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 24)).is_ok()); + assert!(read_within(&bin, &Budget::new(n * 20)).is_err()); + // ASCII values count too. + let (_, doc) = read(ASCII.as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(values > 45 * std::mem::size_of::(), "{values}"); + assert!(parse_within(ASCII.as_bytes(), &|_| None, values - 1).unwrap_err().to_string().contains("memory limit")); +} + +/// [`ASCII`] with two more Models instancing its geometry: `Copy` with the same two materials, +/// `Red only` with just the first. +fn instanced_scene() -> String { + ASCII + .replace( + "\tMaterial: 400,", + "\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\n\tMaterial: 400,", + ) + .replace( + "\tC: \"OO\",500,300\n", + "\tC: \"OO\",500,300\n\tC: \"OO\",301,0\n\tC: \"OO\",100,301\n\tC: \"OO\",400,301\n\tC: \"OO\",500,301\n\tC: \"OO\",302,0\n\tC: \"OO\",100,302\n\tC: \"OO\",400,302\n", + ) +} + +#[test] +fn fbx_models_instancing_one_geometry_share_its_mesh() { + let m = parse(instanced_scene().as_bytes(), &|_| None).unwrap(); + let mesh_of = |name: &str| { + let n = m.nodes.iter().position(|n| n.name == name).unwrap(); + // The mesh sits on the node, or on its geometric-offset child. + m.nodes[n].mesh.or_else(|| m.nodes[n].children.iter().find_map(|&c| m.nodes[c].mesh)).unwrap() + }; + assert_eq!(mesh_of("Shape"), mesh_of("Copy"), "same geometry and materials: one mesh"); + assert_ne!(mesh_of("Shape"), mesh_of("Red only"), "different materials: its own mesh"); + assert_eq!(m.meshes.len(), 2); + // A budget with room for one copy of the geometry's mesh, but not three, still imports. + let one = parse(ASCII.as_bytes(), &|_| None).unwrap(); + let mesh = mesh_bytes(&one.meshes[0]).unwrap(); + let (_, doc) = read(instanced_scene().as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + 2 * mesh).is_ok()); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + mesh).is_err()); +} From e0bb54e4c619470e3a93534fdaf69654b4761549 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 18:05:48 -0600 Subject: [PATCH 047/185] render: hash the comp scene of selected layers that read it into the owner's cache key (#1252) A layer selected by another layer's effect (Displacement Map, Blend...) had its structure, values, matrix and time hashed into the owner's key, but not the comp camera/lights its own scene-reading effects (Caustics, CC Particle World...) see. The owner's key included the scene only when the owner itself ran such an effect, and a cached owner never runs the selected layer's effects, so moving the camera or a light reused stale owner pixels. Dependencies::layer now hashes comp_scene for every selected (or nested) layer that reads it, with the same policy as owner keys. Fixes #1252 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/render/src/cache/dependencies.rs | 5 ++++ crates/render/src/tests.rs | 39 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index eaccd058..483b19da 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -78,6 +78,11 @@ impl Dependencies<'_> { hash_debug(self.h, layer); hash_values(self.h, ctx, layer, &layer.props); hash_debug(self.h, &ctx.world_matrix(layer)); + // A cached owner never runs this layer's effects, so the comp camera and lights its + // scene-reading effects see belong in the owner's key too (#1252). + if super::reads_comp_scene(layer) { + hash_debug(self.h, &crate::comp_scene(ctx, layer)); + } if super::time_dependent(layer, true) { layer.layer_time(ctx.time).0.hash(self.h); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 39e022b0..78577fa9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -953,3 +953,42 @@ fn shape_content_bounds_are_the_visual_edges() { assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}"); } } + +/// #1252: a layer blended with a CC Particle World layer, under a camera panned by `pan` px. +fn scene_reader_dependency(pan: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut source = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); + let vals = [("birthRate", Value::Scalar(2.0)), ("particle/particleType", Value::Enum(2)), ("particle/birthSize", Value::Scalar(0.1))]; + add_effect(&mut p, &mut source, "ec.sim.ccparticleworld", [200.0, 100.0], &vals); + source.switches.video = false; + let mut owner = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); + add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None); + for path in ["transform/position", "transform/poi"] { + let pr = cam.props.prop_mut(path).unwrap(); + let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; + pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); + } + p.comp_mut(cid).unwrap().layers = vec![cam, owner, source]; + (p, cid) +} + +/// #1252: the owner's cache key covers the comp camera and lights that a layer it selects reads, +/// though the owner itself runs no scene-reading effect. +#[test] +fn a_selected_layer_reading_the_comp_scene_invalidates_its_owner() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = scene_reader_dependency(0.0); + let (q, _) = scene_reader_dependency(40.0); + let key = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_ne!(key(&p), key(&q), "moving the camera changes the owner's key"); + let cache = crate::LayerCache::default(); + let here = render_cached(&p, cid, t, Some(&cache)); + let panned = render_cached(&q, cid, t, Some(&cache)); + assert_same(&panned, &render_cached(&q, cid, t, None), "cached render after the camera moved"); + let (a, b) = (particles_x(&here), particles_x(&panned)); + assert!(b < a - 20.0, "the blended particles follow the camera: {a} -> {b}"); +} From 7df16a5e20f7c24a3c702d6c963651fb7b09b85c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 02:56:17 +0200 Subject: [PATCH 048/185] Effects: 4-Color Gradient keeps the layer's alpha with Blending Mode None (refs #1381) --- crates/effects/src/generate.rs | 24 +++++++++++++++++++++++- crates/gpu/src/shaders/fx_generate.wgsl | 6 ++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index b29ff915..fd5a5412 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -138,7 +138,10 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let n = (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * jitter * (8.0 / 255.0); c = c.map(|v| v + n); } - *px = gen_blend(*px, [c[0] * op, c[1] * op, c[2] * op, op], mode); + // None keeps the layer's alpha (#1381), as the Gradient Ramp does: the gradient fills + // a shape or text, not its bounding box. + let a = if gen_mode(mode).is_none() { op * px[3] } else { op }; + *px = gen_blend(*px, [c[0] * a, c[1] * a, c[2] * a, a], mode); } }); b @@ -420,6 +423,25 @@ mod generator_tests { } } + #[test] + fn four_color_none_stays_inside_the_layer_alpha() { + // #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box. + let mut img = Image::filled(40, 24, [1.0, 0.0, 0.0, 1.0]); + for (i, p) in img.data.iter_mut().enumerate() { + let a = [0.0, 0.5, 1.0][i % 40 / 14]; + *p = p.map(|c| c * a); + } + for op in [100.0, 50.0] { + let o = run_fx("ec.generate.fourcolor", &[("opacity", num(op))], img.clone(), 0.0, EffectEnv::default()); + let k = op as f32 / 100.0; + for (src, out) in img.data.iter().zip(&o.img.data) { + assert!((src[3] * k - out[3]).abs() < 1e-6, "opacity {op}: alpha {} became {}", src[3], out[3]); + assert!(out[..3].iter().all(|&c| c <= out[3] + 1e-6), "opacity {op}: premultiplied colour above alpha"); + } + assert!(o.img.data.iter().any(|p| p[1] > 0.1), "opacity {op}: the gradient replaces the layer's colour"); + } + } + #[test] fn checkerboard_blending_none_shows_pattern_only_and_size_from_width() { let set = [("width", num(8.0)), ("height", num(2.0))]; diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index 60fc2f9d..f46b2f0b 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -510,8 +510,10 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3) { let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0); c += vec3(n); } - let op = P.f[8].z; - textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(c * op, op), P.u[0].x)); + let o = textureLoad(src, p, 0); + // None keeps the layer's alpha, as the CPU path does (#1381). + let op = select(P.f[8].z, P.f[8].z * o.w, P.u[0].x == 255u); + textureStore(out, p, gen_blend(o, vec4(c * op, op), P.u[0].x)); } // ---------------------------------------------------------------- Noise / Add Grain From fa6c9ffccd4fbb83fff943db7cdde86a7cea5e82 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 049/185] Web: IndexedDB puts and deletes resolve when their transaction commits (#1242) --- apps/effectcraft-web/js/host.js | 24 +++- apps/effectcraft-web/tests/storage.mjs | 180 +++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 apps/effectcraft-web/tests/storage.mjs diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 3db6ad35..f113c2a2 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -57,6 +57,26 @@ function req(r) { return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); } +// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has +// committed. A request's success doesn't mean the write is stored: the transaction can still +// abort afterwards (quota at commit, the browser closing), rolling it back, and a write +// reported as saved then is never retried (#1242). A failed request aborts the transaction +// with its error; an explicit abort leaves no error, hence the AbortError. +function write(db, store, op) { + return new Promise((res, rej) => { + let tx = null; + try { + tx = db.transaction(store, "readwrite"); + tx.oncomplete = () => res(); + tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError")); + op(tx.objectStore(store)); + } catch (e) { + try { tx?.abort(); } catch {} + rej(e); + } + }); +} + async function idb() { if (!self.indexedDB) throw new Error("no IndexedDB"); const open = indexedDB.open(DB, 1); @@ -70,10 +90,10 @@ async function idb() { return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified })); }, async put(key, data, modified) { - await req(tx("readwrite").put({ key, data: data.slice().buffer, modified })); + await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified })); }, async del(key) { - await req(tx("readwrite").delete(key)); + await write(db, STORE, (s) => s.delete(key)); }, }; } diff --git a/apps/effectcraft-web/tests/storage.mjs b/apps/effectcraft-web/tests/storage.mjs new file mode 100644 index 00000000..0855c167 --- /dev/null +++ b/apps/effectcraft-web/tests/storage.mjs @@ -0,0 +1,180 @@ +// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no +// browser or Wasm build needed: +// +// node apps/effectcraft-web/tests/storage.mjs +// +// A write is acknowledged only once its transaction has committed, not when its request +// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be +// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure. +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const tick = () => new Promise((r) => setTimeout(r, 0)); +globalThis.self = globalThis; + +// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite +// transaction commits once its requests are done, unless it was aborted (which rolls back its +// writes). `fault` makes the next request do something else. +let fault = null; // "abortAfterSuccess" | "requestError" | "throw" +const databases = new Map(); + +class Request { + constructor(tx) { + this.transaction = tx; + this.result = undefined; + this.error = null; + } +} + +class Store { + constructor(tx, rows) { + this.tx = tx; + this.rows = rows; + } + request(apply) { + const tx = this.tx; + if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError"); + const f = fault; + fault = null; + if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError"); + const r = new Request(tx); + tx.pending++; + setTimeout(() => { + tx.pending--; + if (tx.finished) return; + if (f === "requestError") { + r.error = new DOMException("the key already exists", "ConstraintError"); + const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + r.onerror?.(e); + tx.onerror?.(e); + if (!e.defaultPrevented) tx.fail(r.error); + return; + } + r.result = apply(tx.staged); + r.onsuccess?.({ target: r }); + if (f === "abortAfterSuccess") tx.abort(); + tx.maybeCommit(); + }, 0); + return r; + } + put(v) { + return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; }); + } + delete(k) { + return this.request((rows) => { rows.delete(k); }); + } + get(k) { + return this.request((rows) => rows.get(k)); + } + getAll() { + return this.request((rows) => [...rows.values()]); + } +} + +class Transaction { + constructor(db, name) { + this.db = db; + this.name = name; + this.staged = new Map(db.stores.get(name)); + this.pending = 0; + this.finished = false; + this.error = null; + setTimeout(() => this.maybeCommit(), 0); + } + objectStore(name) { + const s = new Store(this, this.staged); + s.keyPath = this.db.keyPaths.get(name); + return s; + } + maybeCommit() { + if (this.finished || this.pending > 0) return; + this.finished = true; + this.db.stores.set(this.name, this.staged); + setTimeout(() => this.oncomplete?.(), 0); + } + fail(error) { + this.error = error; + this.abort(); + } + abort() { + if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError"); + this.finished = true; + setTimeout(() => this.onabort?.(), 0); + } +} + +class Database { + constructor() { + this.stores = new Map(); + this.keyPaths = new Map(); + } + createObjectStore(name, { keyPath }) { + this.stores.set(name, new Map()); + this.keyPaths.set(name, keyPath); + } + transaction(name) { + if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError"); + return new Transaction(this, name); + } +} + +globalThis.indexedDB = { + open(name) { + const r = new Request(null); + setTimeout(() => { + let db = databases.get(name); + if (!db) { + db = new Database(); + databases.set(name, db); + r.result = db; + r.onupgradeneeded?.(); + } + r.result = db; + r.onsuccess?.({ target: r }); + }, 0); + return r; + }, +}; + +const host = await import(pathToFileURL(join(here, "../js/host.js"))); +assert.equal(await host.storeOpen("indexeddb"), "indexeddb"); +const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key); +const bytes = (s) => new TextEncoder().encode(s); +const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data)); + +// A healthy write resolves once committed, and the store has it then. +await host.storePut("project.json", bytes("v1"), 1); +assert.equal(text(stored("project.json")), "v1", "a resolved put is committed"); + +// The put's request succeeds, then the transaction aborts: the put fails, the old value stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back"); + +// The same for a delete: it fails and the entry stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back"); + +// A failed request aborts its transaction with the request's error. +fault = "requestError"; +await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" }); +assert.equal(text(stored("project.json")), "v1"); + +// A request that throws at once (a value that can't be stored) fails the write. +fault = "throw"; +await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" }); +assert.equal(text(stored("project.json")), "v1"); + +// The retry after a failure commits the latest value, and a later delete removes it. +await host.storePut("project.json", bytes("v5"), 5); +assert.equal(text(stored("project.json")), "v5"); +await host.storeDelete("project.json"); +assert.equal(stored("project.json"), undefined, "a resolved delete is committed"); +const all = await host.storeLoadAll(); +assert.deepEqual(all, [], "nothing else was written"); + +await tick(); +console.log("storage: ok"); From f61f8c995846d5c641128cd5c46b7485e7e4e0c7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 050/185] Web: remembering and forgetting Media Browser folders waits for the commit too (#1242) --- apps/effectcraft-web/js/host.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index f113c2a2..bab20f75 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -570,7 +570,7 @@ async function openFolder(handle, remember) { if (remember) { try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle })); + await write(db, "folders", (s) => s.put({ name, handle })); } catch (e) { console.info("media browser: cannot remember the folder", e); } } return { name, entries }; @@ -622,7 +622,7 @@ export async function browseForget(name) { folders.delete(name); try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name)); + await write(db, "folders", (s) => s.delete(name)); } catch {} } From c03cd79b5910d69ec05155866275d75661cbf25a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 051/185] Docs: the IndexedDB storage test (#1242) --- docs/web.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/web.md b/docs/web.md index e73f7a89..7b5cd5c5 100644 --- a/docs/web.md +++ b/docs/web.md @@ -432,6 +432,12 @@ a fake DOM: a picked file that can't be read is reported while the others are ad device-lost notice saves, flushes before reloading and reports a failed flush (`node apps/effectcraft-web/tests/page.mjs`). +`apps/effectcraft-web/tests/storage.mjs` checks the IndexedDB storage backend of `js/host.js` +with a fake IndexedDB: a put or delete resolves only once its transaction has committed, and one +whose transaction aborts after its request succeeded (or whose request fails or throws) rejects +and leaves the stored entry as it was, so the flush keeps it for a retry (#1242) +(`node apps/effectcraft-web/tests/storage.mjs`). + It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that replies when done while the page keeps running, and the Media Browser over browser storage; and From fc3643ad1754c4fb92ad6d8a77728c52310c1fba Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:53 +1000 Subject: [PATCH 052/185] Honor --empty when rendering from the CLI Keep ordinary --empty renders from loading the demo. Preserve explicit project, template, and demo selection, and cover them with real GIF renders. Sort the relative-footage test's expected paths consistently with its output. Co-authored-by: Codex --- apps/effectcraft-cli/src/main.rs | 9 +++-- apps/effectcraft-cli/tests/cli.rs | 55 ++++++++++++++++++++++++++++++ crates/engine/src/footage_check.rs | 3 +- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index fe523807..d36b3ffe 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -660,11 +660,10 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> { let mut s = session(args)?; if args.opt("--template").is_some() { template::prepare(&mut s, args)?; - } else { - match &args.project { - Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?, - None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?, - }; + } else if let Some(p) = &args.project { + s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?; + } else if args.flag("--demo") || !args.flag("--empty") { + s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?; } let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string()); if !args.flag("--queue") { diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 62aefd5d..252f4ab6 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -491,3 +491,58 @@ fn render_bitrate_applies_to_vp9_webm() { assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes"); let _ = std::fs::remove_dir_all(&root); } + +/// Render uses the same empty/project/demo selection as the other CLI commands. +#[test] +fn render_empty_does_not_load_the_demo() { + let root = tmp("render-empty"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let project = root.join("control.ecproj"); + let template = root.join("control.ectemplate"); + let project_path = project.to_str().unwrap(); + let template_path = template.to_str().unwrap(); + let build = r#" +var comp = app.project.items.addComp("Empty control", 64, 36, 1, 1, 30); +var solid = comp.layers.addSolid([0, 1, 0], "Green", 64, 36, 1); +solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity"); +"#; + ok_json(&["script", "--eval", build, "--save-as", project_path]); + let params = json!({"comp": "Empty control", "path": template_path}).to_string(); + let exported = ok_json(&["exec", "essential.exportTemplate", ¶ms, "--project", project_path]); + assert_eq!(exported["controls"], 1, "{exported}"); + let empty = ok_json(&["info", "--empty"]); + assert!(empty["project"]["items"].as_array().unwrap().is_empty(), "{empty}"); + + let render = |options: &[&str], name: &str| { + let output = root.join(name); + assert!(!output.exists()); + let mut args = vec!["render", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()]; + args.extend_from_slice(options); + let reply = ok_json(&args); + assert_eq!(reply["rendered"].as_array().unwrap().len(), 1, "{reply}"); + assert_eq!(PathBuf::from(reply["rendered"][0]["output"].as_str().unwrap()), output); + let bytes = std::fs::read(&output).unwrap(); + assert!(bytes.len() >= 10 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")), "{reply}"); + let dimensions = (u16::from_le_bytes([bytes[6], bytes[7]]), u16::from_le_bytes([bytes[8], bytes[9]])); + (reply["rendered"][0]["comp"].clone(), dimensions) + }; + // Defaults still render the demo, and an explicit demo wins over --empty. + let demo = render(&[], "default.gif"); + assert_eq!(render(&["--demo", "--empty"], "demo.gif"), demo); + // A supplied project wins over both flags, while template mode also accepts --empty. + let project_result = render(&["--project", project_path, "--demo", "--empty"], "project.gif"); + assert_eq!(project_result, (json!("Empty control"), (16, 9))); + assert_eq!(render(&["--template", template_path, "--empty"], "template.gif").1, (16, 9)); + + let output = root.join("empty.gif"); + assert!(!output.exists()); + let (code, reply) = + run_json(&["render", "--empty", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()]); + let output_exists = output.exists(); + eprintln!("EMPTY_RENDER_OBSERVATION status={code} output_exists={output_exists} reply={reply}"); + assert!(!output_exists, "--empty must not render the demo into the requested output"); + assert_eq!(code, 1, "{reply}"); + assert_eq!(reply["error"], "command `renderQueue.add` is not available right now: select or open a composition", "{reply}"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index de14b06c..ca5ff3f7 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -235,7 +235,8 @@ mod tests { paths.sort(); // Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed // separators; `Path` equality compares components, so both spellings are the same file. - let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]; + let mut expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]; + expected.sort(); assert_eq!(paths.len(), expected.len(), "{paths:?}"); for (got, want) in paths.iter().zip(expected) { assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}"); From ce437747eda192e41a861469c17bf68f8e3cb632 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 05:28:14 +0200 Subject: [PATCH 053/185] Expressions: accepting a function completion inserts the call parentheses (fixes #1393) --- crates/ui-egui/src/panels/expr_editor.rs | 77 +++++++++++++++++++++--- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index f800b1a4..f342d563 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -2,6 +2,7 @@ //! Font Size, Syntax Highlighting, Line Numbers, Auto-complete (Tab accepts), Bracket Matching //! and Word Wrap. The Composition viewer uses the shared diagnostics in `expr_bar`. +use effectcraft_engine::commands::expr_tools::language_menu; use effectcraft_engine::prefs::Scripting; use egui::text::{LayoutJob, TextFormat}; use egui::{Color32, FontId, Rect, Stroke, pos2, vec2}; @@ -211,6 +212,37 @@ pub fn completions(text: &str, cursor: usize) -> (String, Vec<&'static str>) { (word, c) } +/// How `name` is called, from the Expression Language menu's signatures: `None` for a +/// property, `Some(true)` for a function that takes arguments, `Some(false)` for one that never +/// does. +fn call_shape(name: &str) -> Option { + let calls: Vec<&str> = + language_menu().into_iter().flat_map(|(_, items)| items).filter_map(|(_, text)| text.strip_prefix(name)?.strip_prefix('(')).collect(); + (!calls.is_empty()).then(|| calls.iter().any(|rest| !rest.starts_with(')'))) +} + +/// Accepts the first auto-complete candidate at char index `cursor`: inserts the rest of the +/// name, plus `()` for a function (#1393), and returns the new cursor: between the parentheses, +/// or after them when the function takes no arguments. +pub fn accept_completion(buf: &mut String, cursor: usize) -> Option { + let (word, cands) = completions(buf, cursor); + let first = cands.first()?; + let at = byte_of(buf, cursor); + let rest = &first[word.len()..]; + let end = cursor + first.chars().count() - word.chars().count(); + let already_called = buf.get(at..).is_some_and(|after| after.starts_with('(')); + match call_shape(first).filter(|_| !already_called) { + Some(takes_args) => { + buf.insert_str(at, &format!("{rest}()")); + Some(end + if takes_args { 1 } else { 2 }) + } + None => { + buf.insert_str(at, rest); + Some(end) + } + } +} + /// Colour runs for JavaScript: (byte start, byte end, colour). pub fn highlight(text: &str, base: Color32, sx: &Syntax) -> Vec<(usize, usize, Color32)> { let b = text.as_bytes(); @@ -320,15 +352,10 @@ pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: if p.auto_complete && child.memory(|m| m.has_focus(id)) && let Some(c) = cursor + && !completions(buf, c).1.is_empty() + && child.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)) { - let (word, cands) = completions(buf, c); - if let Some(first) = cands.first() - && child.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)) - { - let at = byte_of(buf, c); - buf.insert_str(at, &first[word.len()..]); - accepted = Some(c + first.chars().count() - word.chars().count()); - } + accepted = accept_completion(buf, c); } let out = egui::TextEdit::multiline(buf) .id(id) @@ -446,3 +473,37 @@ mod contrast_tests { assert_eq!(col("// note"), Some(sx.comment)); } } + +#[cfg(test)] +mod completion_tests { + use super::*; + + /// #1393: accepting a function inserts the call, with the cursor between the parentheses + /// (after them when the function takes no arguments); a property stays a bare name. + #[test] + fn accepting_a_function_completion_inserts_the_call() { + let mut buf = String::from("x = loopO"); + assert_eq!(accept_completion(&mut buf, 9), Some(12)); + assert_eq!(buf, "x = loopOut()"); + + let mut buf = String::from("wigg"); + assert_eq!(accept_completion(&mut buf, 4), Some(7)); + assert_eq!(buf, "wiggle()"); + + let mut buf = String::from("isClo"); + assert_eq!(accept_completion(&mut buf, 5), Some(10)); + assert_eq!(buf, "isClosed()"); + + let mut buf = String::from("tim"); + assert_eq!(accept_completion(&mut buf, 3), Some(4)); + assert_eq!(buf, "time"); + } + + /// Completing a name already followed by its call does not add a second pair. + #[test] + fn accepting_before_an_existing_call_keeps_one_pair_of_parentheses() { + let mut buf = String::from("loopO(\"cycle\")"); + assert_eq!(accept_completion(&mut buf, 5), Some(7)); + assert_eq!(buf, "loopOut(\"cycle\")"); + } +} From 0f0fef2b150dcbddd6f8dcc7246a472cdb2c80b2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 05:28:41 +0200 Subject: [PATCH 054/185] Contributors: add my real name --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 705feb25..20e4736f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -78,3 +78,7 @@ sync_github_name = true [people.moecr7] real_name = "Moein Saharkhiz" display_name = "onlymoein" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +sync_github_name = false From 217cd62402e74606bbf87a994173656e451b5baa Mon Sep 17 00:00:00 2001 From: Moein Saharkhiz <142007707+onlymoein@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:45:52 -0700 Subject: [PATCH 055/185] Update contributor key: moecr7 -> onlymoein --- contributors/people.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributors/people.toml b/contributors/people.toml index 705feb25..36ddb5e1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -75,6 +75,6 @@ sync_github_name = true real_name = "Robert Lee" sync_github_name = true -[people.moecr7] +[people.onlymoein] real_name = "Moein Saharkhiz" display_name = "onlymoein" From 8041329df8b145a6c38191fc6a620bbc05b38280 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 14 Oct 2026 11:42:13 +0530 Subject: [PATCH 056/185] feat(media-browser): add Copy Source Path --- crates/ui-egui/src/i18n/ui.rs | 7 + crates/ui-egui/src/menus.rs | 7 +- crates/ui-egui/src/panels/media_panels.rs | 8 ++ .../tests/ui_media_browser_copy_path.rs | 125 ++++++++++++++++++ docs/control-protocol.md | 1 + 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/ui_media_browser_copy_path.rs diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4af..bca5f66c 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -228,6 +228,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"), ("Effects & Presets options", "エフェクト&プリセットのオプション"), ("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"), + ("Copy Source Path", "ソースパスをコピー"), ("Effects: {}", "エフェクト:{}"), ("Empty folder", "空のフォルダー"), ("Enable Depth of Field", "被写界深度を有効化"), @@ -772,6 +773,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"), ("Effects & Presets options", "效果和预设选项"), ("Copy Presets Folder Path", "复制预设文件夹路径"), + ("Copy Source Path", "复制源路径"), ("Effects: {}", "效果:{}"), ("Empty folder", "空文件夹"), ("Enable Depth of Field", "启用景深"), @@ -1504,6 +1506,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"), ("Effects & Presets options", "效果和預設集選項"), ("Copy Presets Folder Path", "複製預設集資料夾路徑"), + ("Copy Source Path", "複製來源路徑"), ("Effects: {}", "效果:{}"), ("Empty folder", "空資料夾"), ("Enable Depth of Field", "啟用景深"), @@ -2239,6 +2242,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."), ("Effects & Presets options", "Параметри ефектів і наборів"), ("Copy Presets Folder Path", "Копіювати шлях до папки наборів"), + ("Copy Source Path", "Копіювати шлях до джерела"), ("Effects: {}", "Ефекти: {}"), ("Empty folder", "Порожня тека"), ("Enable Depth of Field", "Увімкнути глибину різкості"), @@ -2791,6 +2795,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."), ("Effects & Presets options", "Opções de Efeitos e predefinições"), ("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"), + ("Copy Source Path", "Copiar caminho da origem"), ("Effects: {}", "Efeitos: {}"), ("Empty folder", "Pasta vazia"), ("Enable Depth of Field", "Ativar profundidade de campo"), @@ -3241,6 +3246,7 @@ const CZECH: &[(&str, &str)] = &[ ("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."), ("Effects & Presets options", "Možnosti efektů a přednastavení"), ("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"), + ("Copy Source Path", "Kopírovat cestu ke zdroji"), ("Effects: {}", "Efekty: {}"), ("Empty folder", "Prázdná složka"), ("Enable Depth of Field", "Povolit hloubku ostrosti"), @@ -3653,6 +3659,7 @@ const CZECH: &[(&str, &str)] = &[ /// order. Terms follow the Spanish menu catalog (composición, material de archivo, cola de procesamiento, /// ajuste preestablecido, método abreviado). Written from the English source. const SPANISH: &[(&str, &str)] = &[ + ("Copy Source Path", "Copiar ruta de origen"), ("Appearance Settings…", "Ajustes de apariencia…"), ("Add to favorites", "Añadir a favoritos"), ("Remove from favorites", "Quitar de favoritos"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c3..b1eba4f0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -291,7 +291,7 @@ fn only_comp(p: &Value) -> Option { /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { - if id == "effects.copyPresetsFolderPath" { + if matches!(id, "effects.copyPresetsFolderPath" | "mediaBrowser.copySourcePath") { return frontend(app, ctx, id, params); } // Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of @@ -880,6 +880,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu let now = ctx.input(|i| i.time); let v = &mut app.ui.viewer; Ok(match id { + "mediaBrowser.copySourcePath" => { + let path = p.get("path").and_then(Value::as_str).filter(|path| !path.is_empty()).ok_or("A source path is required")?; + ctx.copy_text(path.to_string()); + json!({"path": path}) + } "effects.copyPresetsFolderPath" => { if cfg!(target_arch = "wasm32") { return Err("The web app has no local presets folder".into()); diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index 4a0ab6ae..75c96691 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -209,6 +209,14 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::DragAndDrop::set_payload(&ctx, DragPayload::Files(vec![e.path.clone()])); } crate::widgets::context_menu(&resp, |ui| { + let copy = ui.button(tr("Copy Source Path")); + app.auto.add(&format!("mediaBrowser.entry.{}.copySourcePath", e.name), copy.rect, "Copy Source Path"); + if copy.clicked() { + if let Err(e) = crate::menus::invoke(app, &ctx, "mediaBrowser.copySourcePath", json!({"path": e.path})) { + app.ui.status = e; + } + ui.close(); + } if !e.is_dir && ui.button(tr("Import")).clicked() { actions.push(("mediaBrowser.import", json!({"paths": [e.path]}))); ui.close(); diff --git a/crates/ui-egui/tests/ui_media_browser_copy_path.rs b/crates/ui-egui/tests/ui_media_browser_copy_path.rs new file mode 100644 index 00000000..8da34c5b --- /dev/null +++ b/crates/ui-egui/tests/ui_media_browser_copy_path.rs @@ -0,0 +1,125 @@ +use std::sync::Arc; + +use effectcraft_engine::{ + Session, + media_browser::{Browser, Entry, VirtualTree}, +}; +use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind}; +use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +struct TestBrowser(VirtualTree); +impl Browser for TestBrowser { + fn home(&self) -> String { + "/Media".into() + } + fn list(&self, dir: &str, only: bool) -> Result, String> { + self.0.list(dir, only) + } +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, button: PointerButton) -> Vec { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + let pos = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.step(); + let mut copied = Vec::new(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + copied.extend(h.output().platform_output.commands.iter().filter_map(|c| match c { + OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + })); + } + copied +} + +#[test] +fn file_folder_and_virtual_paths_copy_from_the_context_menu_without_document_changes() { + let cases = [ + ("作品 clip.csv", "/Users/example/Shoot 01/作品 clip.csv"), + ("Notes.txt", "./relative notes.txt"), + ("Virtual.csv", "browser://assets/opaque%20作品.csv?version=2"), + ("Folder 作品", "/Media/Folder 作品"), + ]; + let mut tree = VirtualTree::default(); + for (name, path) in cases.iter().take(3) { + tree.add_file(&format!("/Media/{name}"), path, 1, None); + } + tree.add_dir("/Media/Folder 作品"); + let mut session = Session { browser: Some(Arc::new(TestBrowser(tree))), ..Default::default() }; + session.prefs.general.language = "en".into(); + session.execute("comp.new", json!({"width": 64, "height": 36, "duration": 2})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| { + let mut app = EffectcraftApp::new(session); + app.ui.start_screen = false; + app.show_panel(PanelKind::MediaBrowser); + app.ui.maximized = Some(PanelKind::MediaBrowser); + app + }); + h.run_steps(3); + let project = h.state().session.project.clone(); + let revision = h.state().session.revision; + let history = h.state().session.history.undo.len(); + let browser = serde_json::to_value(&h.state().session.state.media_browser).unwrap(); + for (name, path) in cases { + let id = format!("mediaBrowser.entry.{name}.copySourcePath"); + assert!(h.state().auto.find(&id).is_none()); + assert!(click(&mut h, &format!("mediaBrowser.entry.{name}"), PointerButton::Secondary).is_empty()); + h.run_steps(3); + assert!(h.state().auto.find(&id).is_some()); + if name == "作品 clip.csv" + && let Ok(file) = std::env::var("MEDIA_BROWSER_COPY_SNAPSHOT") + { + h.render().unwrap().save(file).unwrap(); + } + assert_eq!(click(&mut h, &id, PointerButton::Primary), [path]); + h.run_steps(3); + assert!(h.state().auto.find(&id).is_none()); + assert_eq!(h.state().session.project, project); + assert_eq!(h.state().session.revision, revision); + assert_eq!(h.state().session.history.undo.len(), history); + assert_eq!(serde_json::to_value(&h.state().session.state.media_browser).unwrap(), browser); + } +} + +#[test] +fn copy_path_control_command_preserves_the_path_and_rejects_bad_arguments() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + let project = app.session.project.clone(); + let revision = app.session.revision; + let history = app.session.history.undo.len(); + for params in [json!({"path": " ./作品 clip.csv "}), json!({}), json!({"path": ""}), json!({"path": 42}), json!({"path": null})] { + let mut reply = None; + let mut out = ctx.run_ui(Default::default(), |ui| { + let (request, _rx) = ControlRequest::new("ui.menu.invoke", json!({"id": "mediaBrowser.copySourcePath", "params": params})); + reply = match control::handle(&mut app, ui.ctx(), &request) { + control::Outcome::Done(value) => Some(value), + _ => panic!("expected immediate reply"), + }; + }); + out.textures_delta.clear(); + let copied: Vec<_> = out + .platform_output + .commands + .iter() + .filter_map(|c| match c { + OutputCommand::CopyText(text) => Some(text.as_str()), + _ => None, + }) + .collect(); + if params.get("path").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) { + assert_eq!(reply, Some(json!({"ok": true, "result": {"path": " ./作品 clip.csv "}}))); + assert_eq!(copied, [" ./作品 clip.csv "]); + } else { + assert_eq!(reply, Some(json!({"ok": false, "error": "A source path is required"}))); + assert!(copied.is_empty()); + } + assert_eq!(app.session.project, project); + assert_eq!(app.session.revision, revision); + assert_eq!(app.session.history.undo.len(), history); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5f7a3b51..4d01ef2e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -37,6 +37,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params |---|---|---| | `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. | | `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). | +| Copy a Media Browser source path | `ui.menu.invoke {id: "mediaBrowser.copySourcePath", params: {path}}` | Copies the exact non-empty path string to the clipboard and returns `{path}`. Supports file/folder paths and virtual source identifiers. Missing, empty or non-string paths return an error. | | UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.` / `timeline.revealAdd.` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` (Ctrl/Cmd+`), `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). | | `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` | | `ui.menu.list` | `{}` | The menu tree with enablement. | From bed289e2a19b96c88d6ef6ea3778704cadc997c0 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 14 Oct 2026 12:09:48 +0530 Subject: [PATCH 057/185] fix(presets): identify copy folder action by label Follow up on #1210: remove the fixed menu index from automation and click handling, and move the preset-folder documentation into the Effects & Presets section. --- crates/ui-egui/src/panels/effects_presets.rs | 15 +++---- crates/ui-egui/tests/ui_presets_folder.rs | 41 +++++++++++--------- docs/agents.md | 18 ++++----- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd8..bb81abf8 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -288,6 +288,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("effects.menu", mr, "Effects & Presets options"); let view = app.ui.effects_view.clone(); let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") }; + let copy_label = format!(" {}", tr("Copy Presets Folder Path")); let menu = vec![ mark(view.depth == "all", "Show Effects for All Color Depths"), mark(view.depth == "32", "Show 32 bpc Effects Only"), @@ -301,7 +302,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { " Refresh List".into(), " Clear Recently Used".into(), "-".into(), - format!(" {}", tr("Copy Presets Folder Path")), + copy_label.clone(), ]; let mut force_scan = false; let separators = menu.iter().filter(|label| *label == "-").count(); @@ -314,7 +315,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.separator(); } else { let resp = ui.selectable_label(false, label); - if i == 12 { + if label == ©_label { app.auto.add("effects.copyPresetsFolderPath", resp.rect, "Copy Presets Folder Path"); } if resp.clicked() { @@ -330,6 +331,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(i) = choice { let v = &mut app.ui.effects_view; match i { + i if menu.get(i) == Some(©_label) => { + if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) { + app.ui.status = e; + } + } 0 => v.depth = "all".into(), 1 => v.depth = "32".into(), 2 => v.depth = "gpu".into(), @@ -338,11 +344,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 7 => v.alphabetical = true, 9 => force_scan = true, 10 => app.ui.effects_recent.clear(), - 12 => { - if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) { - app.ui.status = e; - } - } _ => {} } } diff --git a/crates/ui-egui/tests/ui_presets_folder.rs b/crates/ui-egui/tests/ui_presets_folder.rs index 7ba632a6..8d549163 100644 --- a/crates/ui-egui/tests/ui_presets_folder.rs +++ b/crates/ui-egui/tests/ui_presets_folder.rs @@ -34,26 +34,29 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> Vec { #[test] fn presets_menu_copies_the_configured_folder_without_editing_the_project() { let expected = expected_folder(); - let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| { - let mut app = EffectcraftApp::new(Session::default()); - app.ui.start_screen = false; - app.show_panel(PanelKind::EffectsPresets); - app - }); - h.run_steps(3); - let before = serde_json::to_value(&h.state().session.project).unwrap(); - let revision = h.state().session.revision; - assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none()); - click(&mut h, "effects.menu"); - h.run_steps(3); - if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") { - h.render().unwrap().save(path).unwrap(); + for language in ["en", "ja"] { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| { + let mut app = EffectcraftApp::new(Session::default()); + app.session.prefs.general.language = language.into(); + app.ui.start_screen = false; + app.show_panel(PanelKind::EffectsPresets); + app + }); + h.run_steps(3); + let before = serde_json::to_value(&h.state().session.project).unwrap(); + let revision = h.state().session.revision; + assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none()); + click(&mut h, "effects.menu"); + h.run_steps(3); + if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") { + h.render().unwrap().save(path).unwrap(); + } + assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected.as_str()]); + h.run_steps(2); + assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none()); + assert_eq!(serde_json::to_value(&h.state().session.project).unwrap(), before); + assert_eq!(h.state().session.revision, revision); } - assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected]); - h.run_steps(2); - assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none()); - assert_eq!(serde_json::to_value(&h.state().session.project).unwrap(), before); - assert_eq!(h.state().session.revision, revision); } #[test] diff --git a/docs/agents.md b/docs/agents.md index 1bec71d5..872065f6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -10,15 +10,6 @@ user can do, an agent can do too. There are three ways in: | **CLI** (`effectcraft-cli exec/get/set/...`) | one-shot scripting and CI; JSON output with `--json` | no | | **Control channel** (`effectcraft --control 9877`) | driving and seeing the live UI; see [control-protocol.md](control-protocol.md) | yes | -## Find the presets folder - -The native Effects & Presets panel's options menu includes **Copy Presets Folder Path**. It copies -the folder the panel scans: `EC_PRESETS_DIR` when set, otherwise `~/Documents/EffectCraft/Presets` -using `HOME` or `USERPROFILE`. Relative paths are resolved to an absolute path. The folder may -not exist yet; this action does not create it. -Over the live UI control channel, `ui.menu.invoke {"id":"effects.copyPresetsFolderPath"}` copies -the same path and returns `{path}`. The web app reports that it has no local presets folder. - ## MCP setup Build once with `cargo build --release -p effectcraft-cli`, then register the server. For Claude @@ -714,3 +705,12 @@ open the demo project; without it the app starts with an empty project) and use mode or the raw control channel. A good loop is: `ui_elements` to find an id, `ui_click` or `ui_drag` to act, then `screenshot {"panel":"Timeline"}` to check the result. `render_frame` shows the composition itself at any zoom. + +### Effects & Presets + +The native Effects & Presets panel's options menu includes **Copy Presets Folder Path**. It copies +the folder the panel scans: `EC_PRESETS_DIR` when set, otherwise `~/Documents/EffectCraft/Presets` +using `HOME` or `USERPROFILE`. Relative paths are resolved to an absolute path. The folder may +not exist yet; this action does not create it. +Over the live UI control channel, `ui.menu.invoke {"id":"effects.copyPresetsFolderPath"}` copies +the same path and returns `{path}`. The web app reports that it has no local presets folder. From 1b44c5c98aac86b3e2d9f5d7b33b7125c292cc63 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 09:15:08 +0200 Subject: [PATCH 058/185] Scripting: Stroke and Hue/Saturation parameters use After Effects' match-name numbers (part of #1405) --- crates/script/src/matchnames.rs | 44 ++++++++++++++++++++---- crates/script/src/tests.rs | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 60a40e69..205697b6 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -399,7 +399,8 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { } /// Effects whose documented parameter match names are not numbered in display order: -/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. +/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. Params of +/// these effects that After Effects doesn't have are numbered after the listed ones (#1405). const PARAM_NUMBERS: &[(&str, &str, usize)] = &[ ("ec.generate.fill", "fillMask", 1), ("ec.generate.fill", "color", 2), @@ -408,8 +409,42 @@ const PARAM_NUMBERS: &[(&str, &str, usize)] = &[ ("ec.generate.fill", "opacity", 5), ("ec.generate.fill", "invert", 6), ("ec.generate.fill", "allMasks", 7), + ("ec.generate.stroke", "path", 1), + ("ec.generate.stroke", "color", 2), + ("ec.generate.stroke", "brushSize", 3), + ("ec.generate.stroke", "brushHardness", 4), + ("ec.generate.stroke", "opacity", 5), + ("ec.generate.stroke", "spacing", 6), + ("ec.generate.stroke", "paintStyle", 7), + ("ec.generate.stroke", "start", 8), + ("ec.generate.stroke", "end", 9), + ("ec.generate.stroke", "allMasks", 10), + ("ec.generate.stroke", "strokeSequentially", 11), + // -0001 is a hidden parameter and -0003 Channel Range, which we don't have; the per-channel + // controls are ours (After Effects edits the selected channel with the Master ones). + ("ec.color.huesaturation", "channelControl", 2), + ("ec.color.huesaturation", "hue", 4), + ("ec.color.huesaturation", "saturation", 5), + ("ec.color.huesaturation", "lightness", 6), + ("ec.color.huesaturation", "colorize", 7), + ("ec.color.huesaturation", "colorizeHue", 8), + ("ec.color.huesaturation", "colorizeSaturation", 9), + ("ec.color.huesaturation", "colorizeLightness", 10), ]; +/// The `-NNNN` number of the param `id` at 0-based `pos` among `fx`'s `params`. +fn param_number(fx: &str, params: &[Node], id: &str, pos: usize) -> usize { + let listed = |p: &str| PARAM_NUMBERS.iter().find(|(f, q, _)| *f == fx && *q == p).map(|(_, _, n)| *n); + if let Some(n) = listed(id) { + return n; + } + let Some(last) = PARAM_NUMBERS.iter().filter(|(f, _, _)| *f == fx).map(|(_, _, n)| *n).max() else { + return pos + 1; + }; + let unlisted_before = params.iter().take(pos).filter(|c| listed(c.match_id()).is_none()).count(); + last + unlisted_before + 1 +} + /// Match name of the node at the end of `chain` (layer root's children → … → node). `effect` /// params are numbered `-0001…` in parameter order, like After Effects. pub fn node(chain: &[&Node]) -> String { @@ -432,11 +467,8 @@ pub fn node(chain: &[&Node]) -> String { let fx = parent.and_then(Node::as_group); match fx { Some(g) => { - let i = PARAM_NUMBERS - .iter() - .find(|(fx, p, _)| *fx == g.match_id && *p == id) - .map(|(_, _, n)| *n) - .unwrap_or_else(|| g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1); + let pos = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0); + let i = param_number(&g.match_id, &g.children, id, pos); format!("{}-{i:04}", effect(&g.match_id)) } None => own(), diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 2c821e6a..f47c212f 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -126,6 +126,65 @@ fn fill_parameters_use_documented_match_names() { assert_eq!(o.result, json!("Color|All Masks|ADBE Fill-0007")); } +#[test] +fn stroke_parameters_use_after_effects_match_names() { + // #1405: `ADBE Stroke-0002` is Stroke's Color in After Effects, not All Masks. + let mut s = session(); + let o = ok( + &mut s, + r#"var c = app.project.items.addComp("S", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Stroke"), out = []; + for (var i = 1; i <= 11; i++) out.push(fx.property("ADBE Stroke-" + ("000" + i).slice(-4)).name); + out.push(fx.property("Color").matchName); out"#, + ); + assert_eq!( + o.result, + json!([ + "Path", + "Color", + "Brush Size", + "Brush Hardness", + "Opacity", + "Spacing", + "Paint Style", + "Start", + "End", + "All Masks", + "Stroke Sequentially", + "ADBE Stroke-0002" + ]) + ); +} + +#[test] +fn hue_saturation_parameters_use_after_effects_match_names() { + // #1405: `ADBE HUE SATURATION-0004` is Master Hue in After Effects. The per-channel controls, + // which After Effects doesn't expose, are numbered after its parameters so none takes an AE number. + let mut s = session(); + let o = ok( + &mut s, + r#"var c = app.project.items.addComp("H", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE HUE SATURATION"), out = []; + var n = [2, 4, 5, 6, 7, 8, 9, 10]; + for (var i = 0; i < n.length; i++) out.push(fx.property("ADBE HUE SATURATION-" + ("000" + n[i]).slice(-4)).name); + out.push(fx.property("Red Hue").matchName); out"#, + ); + assert_eq!( + o.result, + json!([ + "Channel Control", + "Master Hue", + "Master Saturation", + "Master Lightness", + "Colorize", + "Colorize Hue", + "Colorize Saturation", + "Colorize Lightness", + "ADBE HUE SATURATION-0011" + ]) + ); +} + #[test] fn undo_steps_without_a_group() { let mut s = session(); From 68d928aff325c9af3682c840b4ef7e14a015b97a Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 14 Oct 2026 13:42:59 +0530 Subject: [PATCH 059/185] fix(render): preserve preceding effects in GPU Echo (#1120) --- crates/gpu/src/tests_fx_noise.rs | 22 ++++++++++++++++++++++ crates/render/src/lib.rs | 26 +++++++++++++++++--------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/gpu/src/tests_fx_noise.rs b/crates/gpu/src/tests_fx_noise.rs index 0fc38442..476f0864 100644 --- a/crates/gpu/src/tests_fx_noise.rs +++ b/crates/gpu/src/tests_fx_noise.rs @@ -203,6 +203,28 @@ fn echo_and_posterize_time() { } } +#[test] +fn echo_after_star_burst_preserves_the_stars() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for operator in 0..7 { + for posterize in [false, true] { + let mut s = Scene::new(depth); + let mut l = s.solid([1.0; 3], 97, 61); + s.effect(&mut l, "ec.sim.ccstarburst", &[("gridSpacing", n(8.0)), ("size", n(40.0))]); + s.effect(&mut l, "ec.time.echo", &[("numberOfEchoes", n(2.0)), ("echoTime", n(-0.1)), ("decay", n(0.6)), ("echoOperator", e(operator))]); + if posterize { + s.effect(&mut l, "ec.time.posterizetime", &[("frameRate", n(4.0))]); + } + s.push(l); + let t = Tick::from_seconds_f64(0.6); + let label = format!("Star Burst + Echo {operator} posterize={posterize} {depth:?}"); + check(&label, compare_at(&s, opts(), t), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } + } + } +} + #[test] fn median_levels_match_cpu_division() { let Some(g) = crate::tests::gpu() else { return }; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6..808356f1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -216,7 +216,7 @@ struct FxHost<'r, 'a, 'c> { /// whose effects run on the comp below). origin: [f64; 2], /// Index of the effect being rendered (bounds `self_at` to effects before it). - index: std::sync::atomic::AtomicUsize, + index: usize, original: Option<&'c Buf>, } @@ -307,7 +307,7 @@ impl EffectHost for FxHost<'_, '_, '_> { if self.r.depth > MAX_FX_DEPTH || self.layer.switches.adjustment { return None; } - let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed)); + let n = effects.min(self.index); let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time)); let sub = self.r.nested(); sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone()) @@ -337,8 +337,7 @@ impl EffectHost for FxHost<'_, '_, '_> { } fn params_at(&self, layer_time: f64) -> Option { - let i = self.index.load(std::sync::atomic::Ordering::Relaxed); - let g = self.layer.effects()?.groups().nth(i)?; + let g = self.layer.effects()?.groups().nth(self.index)?; let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time))); Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr))) } @@ -1158,7 +1157,7 @@ impl<'a> Renderer<'a> { buf }); } - let host = FxHost { r: self, ctx, layer, origin, index: Default::default(), original: original.as_ref() }; + let host = FxHost { r: self, ctx, layer, origin, index: 0, original: original.as_ref() }; let env = EffectEnv { masks: &mask_shapes, host: Some(&host), @@ -1192,12 +1191,21 @@ impl<'a> Renderer<'a> { { let run: Vec<_> = stack[k..].iter().take_while(|(_, _, s)| s.gpu && a.supports_effect(s.id)).collect(); let params: Vec = run.iter().map(|(_, g, _)| self.effect_params(ctx, layer, g)).collect(); + let hosts: Vec<_> = run.iter().map(|(i, _, _)| FxHost { index: *i, ..host }).collect(); let steps: Vec = run .iter() .zip(¶ms) - .map(|((i, g, s), params)| FxStep { + .zip(&hosts) + .map(|(((i, g, s), params), host)| FxStep { spec: s, - ctx: EffectCtx { params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env: EffectEnv { effect_index: *i, ..env } }, + ctx: EffectCtx { + params, + time: lt.seconds(), + layer_size, + seed: g.uid as u32, + adjustment, + env: EffectEnv { host: Some(host), effect_index: *i, ..env }, + }, }) .collect(); let t0 = web_time::Instant::now(); @@ -1212,8 +1220,8 @@ impl<'a> Renderer<'a> { } k += 1; let params = self.effect_params(ctx, layer, g); - host.index.store(i, std::sync::atomic::Ordering::Relaxed); - let env = EffectEnv { effect_index: i, ..env }; + let host = FxHost { index: i, ..host }; + let env = EffectEnv { host: Some(&host), effect_index: i, ..env }; let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; let t0 = web_time::Instant::now(); target.cpu(&mut |buf| { From 69d17d89afead1ce66d10429c661fb74be5571bc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:25:46 +0200 Subject: [PATCH 060/185] Keyframes: a path pasted onto Position becomes a motion path (fixes #1425) --- crates/engine/src/commands/anim.rs | 91 +++++++++++++++++++++++++++- crates/engine/src/commands/edit.rs | 7 +++ crates/engine/src/tests_menu_cmds.rs | 54 +++++++++++++++++ docs/target-app-parity.md | 3 + 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3ea27b0d..32584511 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -1,8 +1,8 @@ //! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key //! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs. -use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease}; -use effectcraft_project::{LayerId, Property, Uid}; +use effectcraft_keyframe::{Ease, Interp, Keyframe, ShapePath, Value as KValue, key_at, retime_roving, side_ease}; +use effectcraft_project::{ItemId, LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -61,6 +61,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if clip.is_empty() { return Err(bad("keys.paste", "no keyframes have been copied")); } + // A copied Mask Path / shape Path key pasted onto Position becomes a motion path (#1425). + if let [c] = clip.as_slice() + && let Some(KValue::Path(path)) = c.keys.first().map(|k| &k.value) + && let Some(target) = path_target(s, p)? + { + return paste_path(s, p, target, path); + } let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let t = time_p(s, p, Some(comp)); @@ -134,6 +141,86 @@ fn paste(s: &mut Session, p: &Value) -> Result { }) } +/// The spatial property (Position, Anchor Point…) a copied path pastes onto as a motion path: +/// the `prop` / `path` given, else a selected one of the target layers. `None` when there's none. +pub(super) fn path_target(s: &Session, p: &Value) -> Result> { + let (cid, targets) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let motion = |pr: &Property| pr.spatial && !pr.static_only && matches!(pr.value, KValue::Vec2(_) | KValue::Vec3(_)); + let explicit = targets.first().and_then(|lid| { + let l = comp.layer(*lid)?; + let pr = match p.get("prop").and_then(Value::as_u64) { + Some(u) => l.props.find(u), + None => l.props.prop(str_p(p, "path")?), + }?; + Some((*lid, pr)) + }); + if p.get("prop").is_some() || p.get("path").is_some() { + return Ok(explicit.filter(|(_, pr)| motion(pr)).map(|(lid, pr)| (cid, lid, pr.uid))); + } + let selected = s + .state + .selected_props + .iter() + .filter(|(lid, _)| targets.contains(lid)) + .find(|(lid, uid)| comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(motion)); + Ok(selected.map(|(lid, uid)| (cid, *lid, *uid))) +} + +/// Paste a mask or shape path onto a spatial property as After Effects does: a keyframe per +/// vertex with the path's tangents, from the CTI over 2 s (a closed path ends back on its first +/// vertex), the inner keys roving so the layer moves at one speed. Like After Effects, the +/// vertices are pasted as they are (layer-space numbers, not converted to the target's space). +pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, LayerId, Uid), path: &ShapePath) -> Result { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = time_p(s, p, Some(comp)); + let target = comp.layer(lid).ok_or(EngineError::NoComp)?; + let mut order: Vec = (0..path.vertices.len()).collect(); + if path.closed && !order.is_empty() { + order.push(0); + } + if order.is_empty() { + return Err(bad("keys.paste", "the copied path has no points")); + } + let span = Tick::from_seconds_f64(2.0).0 / i64::try_from(order.len().saturating_sub(1).max(1)).unwrap_or(i64::MAX); + let at = |xy: Option<&[f64; 2]>| { + let [x, y] = xy.copied().unwrap_or_default(); + [x, y, 0.0] + }; + let last = order.len() - 1; + let keys: Vec<(Tick, KValue, [f64; 3], [f64; 3], bool)> = order + .iter() + .enumerate() + .map(|(i, &v)| { + let [x, y, z] = at(path.vertices.get(v)); + let value = match target.props.find(uid).map(|pr| &pr.value) { + Some(KValue::Vec2(_)) => KValue::Vec2([x, y]), + _ => KValue::Vec3([x, y, z]), + }; + let time = target.layer_time(t + Tick(span.saturating_mul(i64::try_from(i).unwrap_or(i64::MAX)))); + (time, value, at(path.in_tangents.get(v)), at(path.out_tangents.get(v)), i != 0 && i != last) + }) + .collect(); + s.edit("Paste Keyframes", merge_p(p), |proj, st| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?; + let mut sel = vec![]; + for (time, value, spatial_in, spatial_out, roving) in keys { + let k = Keyframe { spatial_in, spatial_out, spatial_auto: false, roving, ..Keyframe::new(time, value) }; + match pr.keys.binary_search_by(|x| x.time.cmp(&time)) { + Ok(i) => pr.keys[i] = k, + Err(i) => pr.keys.insert(i, k), + } + sel.push(time); + } + // Re-timing keeps the keys' order, so the pasted keys are found again by index. + let pasted: Vec = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect(); + retime_roving(&mut pr.keys, pr.spatial); + st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect(); + Ok(json!(st.selected_keys.len())) + }) +} + /// Select every keyframe of the selected properties, or of the given / selected layers. fn select_all(s: &mut Session, p: &Value) -> Result { let (cid, mut layers) = layers_p(s, p)?; diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 06ba43b1..10bb10d1 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -495,6 +495,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if let Some(clip) = s.state.link_clipboard.clone() { return paste_links(s, clip); } + // A copied Mask Path / shape Path pasted onto Position becomes a motion path (#1425). + if let [(_, effectcraft_keyframe::Value::Path(path))] = s.state.value_clipboard.as_slice() + && let Some(target) = super::anim::path_target(s, p)? + { + let path = path.clone(); + return super::anim::paste_path(s, p, target, &path); + } if !s.state.value_clipboard.is_empty() { return paste_properties(s, p); } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f..6310e252 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1396,6 +1396,60 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() assert_eq!(vals, vec![10.0, 0.0], "reversed"); } +/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion +/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI, +/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers +/// as they are, so the source layer's and the target parent's transforms don't move them. +#[test] +fn a_copied_path_pastes_onto_position_as_a_motion_path() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + // A moved and scaled source: converting to comp space would give 2v + (200, 100). + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap(); + let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]); + s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap(); + // B's parent would shift a converted Position by (0, −50). + let parent = solid(&mut s, "#0000ff"); + s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap(); + let b = solid(&mut s, "#00ff00"); + s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap(); + let motion = |s: &Session, id: u64| { + let l = layer(s, id); + let pr = l.props.prop("transform/position").unwrap().clone(); + pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::>() + }; + let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| { + let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]]; + assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}"); + for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() { + assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}"); + assert_eq!(*roving, i != 0 && i != 4, "key {i} roving"); + assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}"); + } + assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI"); + assert!(keys.windows(2).all(|w| w[0].0 < w[1].0)); + assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent"); + }; + // A Mask Path keyframe, pasted at 1 s. + s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, b), 1.0); + // The same path as an unanimated mask, pasted at 5 s onto an unparented layer. + s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let c = solid(&mut s, "#ffffff"); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, c), 5.0); +} + #[test] fn j_and_k_stop_at_keys_markers_and_the_work_area() { let mut s = comp(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 89464bcd..2301fae7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -746,6 +746,9 @@ them: scripts, ScriptUI panels, effect plug-ins, with a guide to writing a panel way and move them however slowly they are dragged; the same Shift / Ctrl / Ctrl+Alt clicks. - **Paste** (M5.13): keys copied from several layers paste onto as many layers in order; Paste Reversed Keyframes follows Paste's rules (stretch, target property, Hold-only properties). + A Mask Path or shape Path (its value or a key) pasted onto Position becomes a motion path: a + key per vertex with the path's tangents, over 2 s from the CTI, the inner keys roving, the + vertices pasted as they are (layer space, as in After Effects) (#1425). - **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected key's property, time and value. From 78b4fe4170c998cf21dce86f98fcdb5c7376c91d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:44:59 +0200 Subject: [PATCH 061/185] Import: AIFF files import, including EffectCraft's own AIFF renders (fixes #1428) --- Cargo.lock | 1 + Cargo.toml | 3 +++ crates/export/tests/roundtrip.rs | 25 +++++++++++++++++++++++++ crates/media/Cargo.toml | 2 ++ 4 files changed, 31 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 38999ed0..195b59d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1440,6 +1440,7 @@ dependencies = [ "log", "rayon", "serde_json", + "symphonia", "thiserror 2.0.21", ] diff --git a/Cargo.toml b/Cargo.toml index 47c8d417..e64595cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,9 @@ filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned +# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. +symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/roundtrip.rs index 94b83991..0fc74701 100644 --- a/crates/export/tests/roundtrip.rs +++ b/crates/export/tests/roundtrip.rs @@ -613,3 +613,28 @@ fn wav_and_aiff_audio_only() { } } } + +/// An AIFF EffectCraft renders imports back (#1428): plain 16-bit and AIFF-C `fl32`. +#[test] +fn rendered_aiff_imports_back() { + use effectcraft_export::render_queue::AudioFormat; + let d = out_dir("aiff-import"); + let wav = d.join("tone.wav"); + write_tone(&wav, 2.0); + let (p, cid) = project(Some(&wav)); + let pool = MediaPool::new(); + for (format, name) in [(AudioFormat::S16, "s16.aif"), (AudioFormat::F32, "f32.aif")] { + // 44.1 kHz, not the 48 kHz the samples are read back at: a wrong rate shows in the duration. + let om = OutputModule { audio_sample_rate: 44_100, audio_channels: 2, audio_format: format, ..OutputModule::for_format(OutputFormat::Aiff) }; + let path = d.join(name); + assert!(run(&p, cid, &pool, &om, &path).audio); + let f = effectcraft_media::probe(&path).unwrap_or_else(|e| panic!("{name}: {e}")); + assert!(f.has_audio && !f.has_video, "{name}"); + assert_eq!(f.duration, Tick::from_seconds_f64(1.2), "{name}: 52 920 frames at 44.1 kHz"); + // Tone on the left, silence on the right: the two channels are read as two. + let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000); + let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied())); + assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}"); + assert!(r < 0.01, "{name}: right RMS {r}"); + } +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index a3ff624b..bb47dccb 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -24,6 +24,8 @@ filmcraft-isobmff = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } +# AIFF import (#1428): enables the aiff reader of filmcraft-codecs' symphonia. +symphonia = { workspace = true } exr = { workspace = true } gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] } From e26dd21af742818ede2d489b1a7a1107f45c3e2e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:47:04 +0200 Subject: [PATCH 062/185] CLI: a failed render under run/exec exits 1, like render --queue (part of #1426) --- apps/effectcraft-cli/src/main.rs | 21 +++++++++++++++++++++ apps/effectcraft-cli/tests/cli.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index fe523807..b355e820 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -493,6 +493,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { }; let mut b = backend(args, true)?; let r = b.exec(&id, params)?; + if let Some(e) = failed_render(&id, &r) { + return Err(Failure::Error(e)); + } let saved = maybe_save(&mut b, args)?; emit(&with_saved(r, saved), json_out); } @@ -515,6 +518,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { }; i += 1; let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?; + if let Some(e) = failed_render(id, &r) { + return Err(Failure::Error(format!("{id}: {e}"))); + } if !json_out { emit(&r, false); } @@ -637,6 +643,21 @@ fn positional(args: &Args, usage: &str) -> Result<[String; N], F Ok(std::array::from_fn(|i| args.pos[i].clone())) } +/// `renderQueue.render` reports a failed item in its result, not as an error. Under `run`/`exec` +/// that item fails the command (#1426), as it fails `render --queue`. +fn failed_render(id: &str, r: &Value) -> Option { + if id != "renderQueue.render" { + return None; + } + let failed: Vec = r["items"] + .as_array() + .into_iter() + .flatten() + .filter_map(|i| Some(format!("{}: {}", i["compName"].as_str().unwrap_or("?"), i["status"].get("Failed")?.as_str().unwrap_or("failed")))) + .collect(); + (!failed.is_empty()).then(|| failed.join("; ")) +} + fn with_saved(v: Value, saved: Option) -> Value { match saved { None => v, diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 62aefd5d..9ae64a69 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -462,6 +462,32 @@ fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() { std::fs::remove_dir_all(config).unwrap(); } +/// `run`/`exec renderQueue.render` (#1426): a failed item is a command error, exit 1 like +/// `render --queue`, not a success with the failure buried in the JSON. +#[test] +fn a_failed_render_under_run_and_exec_exits_1() { + let root = tmp("render-fails"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + // The output's folder is a file, so the encoder cannot open the output. + let blocker = root.join("not-a-folder"); + std::fs::write(&blocker, "x").unwrap(); + let out = blocker.join("out.gif"); + let add = json!({"comp": "T", "format": "gif", "output": out.to_str().unwrap()}).to_string(); + let project = root.join("q.ecproj"); + let steps = ["comp.new", r#"{"name":"T","width":16,"height":16,"duration":0.1}"#, "layer.newSolid", "{}", "renderQueue.add", &add]; + ok_json(&[&["run", "--empty", "--save-as", project.to_str().unwrap()][..], &steps[..]].concat()); + + let (code, v) = run_json(&[&["run", "--empty"][..], &steps[..], &["renderQueue.render", r#"{"wait":true}"#][..]].concat()); + assert_eq!(code, 1, "{v}"); + let e = v["error"].as_str().unwrap_or_else(|| panic!("{v}")); + assert!(e.starts_with("renderQueue.render: T: "), "{e}"); + + let (code, v) = run_json(&["exec", "renderQueue.render", "--project", project.to_str().unwrap()]); + assert_eq!(code, 1, "{v}"); + assert!(v["error"].as_str().is_some_and(|e| e.starts_with("T: ")), "{v}"); +} + /// `render --bitrate` (#440) targets the bitrate in VP9 WebM. #[test] fn render_bitrate_applies_to_vp9_webm() { From 449e9edd5908f332e8775df6899902e0cc1c9e0d Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 14 Oct 2026 17:21:58 +0500 Subject: [PATCH 063/185] Render Queue: resolve a bare project filename against the working directory `resolve_output` takes the project's folder from `Path::new(path).parent()`. For a project saved under a bare filename that is the empty path, which the following `filter` discarded, so a saved project fell through to the unsaved-project default and relative outputs went to ~/Movies (the user profile folder on Windows) instead of next to the project. --project proj.ecproj ~/Movies/clip.mp4 --project ./proj.ecproj ./clip.mp4 Both spellings name a project in the working directory, so both now resolve there, as the doc comment on `resolve_output` already describes. Closes #1427 --- crates/engine/src/render_queue.rs | 11 +++++++++-- crates/engine/src/tests_m145.rs | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 56754e7c..74b33315 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -362,8 +362,15 @@ impl Session { // Settings ▸ Export ▸ Default Output Folder, else the project's folder. let folder = self.prefs.export.default_output_folder.trim(); let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder)); - let base = - base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty())); + // A project saved under a bare filename has the empty path as its parent. It sits in the + // working directory, so resolve against that rather than falling through to the + // unsaved-project default below (#1427). + let base = base.or_else(|| { + self.path + .as_deref() + .and_then(|s| std::path::Path::new(s).parent()) + .map(|d| if d.as_os_str().is_empty() { std::path::PathBuf::from(".") } else { d.to_path_buf() }) + }); // Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory // is `/` for an app started from the macOS Finder, where nothing can be written (#446). let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from); diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index e52e3ffb..93125af1 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -280,6 +280,28 @@ fn import_export_helpers() { assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]); } +#[test] +fn render_queue_resolves_a_bare_project_filename_against_the_working_directory() { + use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem}; + let mut s = session(); + let cid = s.active_comp_id().unwrap(); + let mut item = RenderQueueItem::new(1, cid); + item.output.output = "clip.mp4".into(); + item.output.format = OutputFormat::H264; + + // `--project ./proj.ecproj`: the parent is `.`, so the output goes beside the project. + s.path = Some("./proj.ecproj".into()); + let dotted = s.resolve_output(&item).unwrap(); + + // `--project proj.ecproj`: the parent is the empty path, which must mean the same folder + // rather than the unsaved-project default (#1427). + s.path = Some("proj.ecproj".into()); + let bare = s.resolve_output(&item).unwrap(); + + assert_eq!(PathBuf::from(&bare), PathBuf::from(&dotted), "a bare filename resolved to {bare} instead of {dotted}"); + assert_eq!(PathBuf::from(&bare), PathBuf::from("./clip.mp4")); +} + #[test] fn render_queue_uses_default_folder_bit_depth_names_and_segments() { use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem}; From cc29d90943e1dfaefb3e0b4eccf4fab9184a39e7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 14:22:15 +0200 Subject: [PATCH 064/185] Import: item.metadata counts a WebM's last frame when its duration is not a whole millisecond (fixes #1429) --- crates/engine/src/media_browser.rs | 4 +++- crates/host/tests/webm_metadata.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 crates/host/tests/webm_metadata.rs diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 5edd67ca..12c002d2 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -312,7 +312,9 @@ pub fn footage_metadata(f: &Footage) -> Value { "pixelAspect": f.pixel_aspect, "frameRate": f.frame_rate.as_f64(), "duration": f.duration.seconds(), - "frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.duration)) } else { None }, + // Rounded like the media pool's frame count: Matroska stores the duration in whole + // milliseconds, a hair short of the last frame at 30 fps (#1429). + "frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.frame_rate.snap_nearest(f.duration))) } else { None }, "hasVideo": f.has_video, "hasAudio": f.has_audio, "alpha": format!("{:?}", f.alpha), diff --git a/crates/host/tests/webm_metadata.rs b/crates/host/tests/webm_metadata.rs new file mode 100644 index 00000000..2e3fed9d --- /dev/null +++ b/crates/host/tests/webm_metadata.rs @@ -0,0 +1,28 @@ +//! A WebM rendered by EffectCraft and imported back reports its real frame count (#1429). Matroska +//! stores the duration in whole milliseconds, so at 30 fps a 1-frame file lasts 33 ms, just short +//! of one 33.33 ms frame. + +use serde_json::json; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("effectcraft-host-webm-meta-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +#[test] +fn a_webm_at_30_fps_reports_every_frame() { + for n in [1u32, 4] { + let mut s = effectcraft_host::session(); + let out = tmp(&format!("frames-{n}.webm")); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "BG", "color": "#142850"})).unwrap(); + let span = json!({"comp": "Main", "format": "webm", "output": out, "timeSpan": "custom", "start": 0, "duration": n as f64 / 30.0}); + s.execute("renderQueue.add", span).unwrap(); + s.execute("renderQueue.render", json!({"wait": true})).unwrap(); + let r = s.execute("file.import", json!({"paths": [out.clone()]})).unwrap(); + let m = s.execute("item.metadata", json!({"item": r["items"][0]})).unwrap(); + let _ = std::fs::remove_file(&out); + assert_eq!(m["item"]["frames"], json!(n), "{n} rendered frames: {m}"); + } +} From 05955c08b4fd025f1a09882df72e6dbff1a8148a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 10:15:34 -0400 Subject: [PATCH 065/185] Release 0.10.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38999ed0..ce8fe123 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.9.2" +version = "0.10.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1168,7 +1168,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1180,14 +1180,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.9.2" +version = "0.10.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.9.2" +version = "0.10.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1203,7 +1203,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.9.2" +version = "0.10.0" dependencies = [ "proptest", "serde", @@ -1211,7 +1211,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1228,7 +1228,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1261,7 +1261,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.9.2" +version = "0.10.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1308,7 +1308,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.9.2" +version = "0.10.0" dependencies = [ "proptest", "serde", @@ -1316,7 +1316,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.9.2" +version = "0.10.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1338,14 +1338,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.9.2" +version = "0.10.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1370,7 +1370,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1389,7 +1389,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1401,7 +1401,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1418,7 +1418,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-model", "effectcraft-pdf", @@ -1445,7 +1445,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-geom", "image", @@ -1457,14 +1457,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.9.2" +version = "0.10.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1476,7 +1476,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-raster", "effectcraft-svg", @@ -1490,7 +1490,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1502,7 +1502,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1515,14 +1515,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.9.2" +version = "0.10.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1531,7 +1531,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.9.2" +version = "0.10.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1570,7 +1570,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.9.2" +version = "0.10.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1591,7 +1591,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1603,7 +1603,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.9.2" +version = "0.10.0" dependencies = [ "proptest", "serde", @@ -1611,7 +1611,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1623,7 +1623,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.9.2" +version = "0.10.0" dependencies = [ "craft-ui", "effectcraft-color", @@ -1655,11 +1655,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.9.2" +version = "0.10.0" [[package]] name = "effectcraft-web" -version = "0.9.2" +version = "0.10.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -6469,7 +6469,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.9.2" +version = "0.10.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 47c8d417..9f4e9d17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.9.2" +version = "0.10.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 9846c7255d8d92ae9bb976949731acd1bbef7855 Mon Sep 17 00:00:00 2001 From: wixar172 Date: Sun, 11 Oct 2026 18:59:47 +0300 Subject: [PATCH 066/185] Shapes: a stroke's buffer is padded by the miter limit only where it has miter joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draw_bounds padded every stroke by width × miter limit / 2 on each side, joins or not. A straight 1080 px wide wipe used as a track matte became a 4326×6366 layer buffer (~440 MB as RGBA f32), made anew every frame while its trim animates, then cached on the CPU and uploaded to the GPU. With 12 frames exported in parallel a 16 GB Mac ran out of memory and froze. Now the miter limit applies only to miter-joined paths with a join (two or more segments, or closed); square caps reach √2 half widths, other strokes one; Taper and Wave keep the widest padding. Rendered pixels are unchanged. A full-res frame of the affected ad: 1.7 GB → 0.7 GB peak. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/shapes.rs | 17 +++++++++++++- crates/render/src/shapes/tests.rs | 37 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index aa46fa50..bd59ad83 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -542,10 +542,25 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option { Paint::Gradient { stroke: Some(style), .. } => Some(style), _ => None, }; - let grow = stroke.map_or(1.0, |style| style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0); + let grow = stroke.map_or(1.0, |style| style.width * stroke_reach(style, paths) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0); Some(b.inflate(grow, grow)) } +/// How far a stroke can reach past its path, in half widths: the miter limit only where a +/// miter join exists (a path of two or more segments, or a closed one), √2 at square cap +/// corners, else one. Padding every stroke by the miter limit made a 1080 px straight line +/// (a wipe matte) a 4326×6366 buffer, ~440 MB per layer per frame. +fn stroke_reach(style: &StrokeStyle, paths: &[BezPath]) -> f64 { + if style.is_variable() { + // Taper and Wave can widen the stroke: keep the widest padding. + return style.miter.max(std::f64::consts::SQRT_2); + } + let joins = + style.join == Join::Miter && paths.iter().any(|p| p.segments().nth(1).is_some() || p.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath))); + let cap = if style.cap == Cap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; + if joins { style.miter.max(cap) } else { cap } +} + /// Local-space visual bounds of a draw, as After Effects' layer bounds (`sourceRectAtTime` with /// extents): the path bounds, plus half the stroke width for strokes. No anti-aliasing or miter /// padding, so snapping and Align put edges exactly together (#352). diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 0a5d6893..3946fc9d 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -424,3 +424,40 @@ fn radial_gradient_highlight_moves_the_focal_point() { // The outer circle is unchanged: points just outside it are black in both. assert!(a.get(100, 182)[0] < 0.03 && b.get(100, 182)[0] < 0.03 && b.get(100, 17)[0] < 0.03); } + +/// The layer-space buffer `super::render` draws the comp's only shape layer into. +fn shape_buf(p: &Project, cid: ItemId) -> effectcraft_effects::Buf { + let comp = p.comp(cid).unwrap(); + let ctx = crate::eval::EvalCtx::new(p, cid, comp, Tick::ZERO); + let layer = &comp.layers[0]; + super::render(&ctx, layer, layer.props.sub("contents").unwrap(), 1.0) +} + +#[test] +fn straight_stroke_buffer_is_not_padded_by_the_miter_limit() { + // A 1080 px wide vertical wipe (a track matte): no joins, so the buffer is the line plus half + // the width, not 4× (Miter Limit 4) the width on every side (4326×6366, ~440 MB per frame). + let (p, cid) = shape_comp_sized(1080, 1920, |ids| vec![line_path(ids, [0.0, 1020.0], [0.0, -1020.0]), build::shape_stroke(ids, RED, 1080.0)]); + let b = shape_buf(&p, cid); + assert!(b.img.width <= 1090 && b.img.height <= 3130, "{}×{}", b.img.width, b.img.height); +} + +#[test] +fn miter_corner_still_fits_its_buffer() { + // A sharp V with a 20 px miter-joined stroke: the miter tip reaches well past half the width. + let (p, cid) = shape_comp(|ids| { + let sp = effectcraft_keyframe::ShapePath { + vertices: vec![[-40.0, -30.0], [0.0, 20.0], [40.0, -30.0]], + in_tangents: vec![[0.0; 2]; 3], + out_tangents: vec![[0.0; 2]; 3], + closed: false, + feather: Vec::new(), + }; + vec![build::shape_path(ids, sp), build::shape_stroke(ids, RED, 20.0)] + }); + // The miter tip of the V points down from (100, 70): its pixels are drawn, not clipped. + let img = frame(&p, cid); + assert!(alpha(&img, 100, 80) > 0.9, "{}", alpha(&img, 100, 80)); + let b = shape_buf(&p, cid); + assert!(b.img.height as f64 >= 50.0 + 20.0 * 4.0 * 0.5, "{}", b.img.height); +} From c3b88d0d76770f41562f68f6d618950c630ec166 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:01:41 +0300 Subject: [PATCH 067/185] pdf: don't slice glyph names at a non-char boundary in name_to_unicode --- crates/pdf/src/encoding.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/pdf/src/encoding.rs b/crates/pdf/src/encoding.rs index ebb63c32..a9f0c840 100644 --- a/crates/pdf/src/encoding.rs +++ b/crates/pdf/src/encoding.rs @@ -208,10 +208,10 @@ pub fn name_to_unicode(name: &str) -> Option { if let Some((_, u)) = NAMES.iter().find(|(n, _)| *n == base) { return char::from_u32(*u); } - if let Some(h) = base.strip_prefix("uni") - && h.len() >= 4 - { - return u32::from_str_radix(&h[..4], 16).ok().and_then(char::from_u32); + // `get(..4)` also insists on a character boundary: a name like `uni\u{20AC}\u{20AC}` used to + // panic slicing inside a multi-byte character. + if let Some(x) = base.strip_prefix("uni").and_then(|h| h.get(..4)) { + return u32::from_str_radix(x, 16).ok().and_then(char::from_u32); } if let Some(h) = base.strip_prefix('u') && (4..=6).contains(&h.len()) @@ -386,4 +386,12 @@ mod tests { assert_eq!(unicode_to_name('Ω'), "Omega"); assert_eq!(unicode_to_name('中'), "uni4E2D"); } + + /// A glyph name like `uni\u{20AC}\u{20AC}` (multi-byte UTF-8 after `uni`) must not slice + /// inside a character. + #[test] + fn multibyte_glyph_names_do_not_panic() { + assert_eq!(name_to_unicode("uni\u{20AC}\u{20AC}"), None); + assert_eq!(name_to_unicode("uni\u{20AC}"), None); + } } From 66a29505a580d91c7ee6b4eba123e3cc7c88a9fa Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Thu, 15 Oct 2026 00:19:47 +0900 Subject: [PATCH 068/185] i18n(ja): the Settings dialog in Japanese Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/ui.rs | 183 ++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4af..7ac3943d 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -631,6 +631,189 @@ const JAPANESE: &[(&str, &str)] = &[ ("After Effects project", "After Effectsプロジェクト"), ("Lottie animation", "Lottieアニメーション"), ("Project", "プロジェクト"), + ("General", "一般"), + ("Language", "言語"), + ("Levels of Undo", "取り消しレベル"), + ("Path Point and Handle Size", "パスポイントとハンドルサイズ"), + ("Recent Projects Shown", "最近使用したプロジェクトの表示数"), + ("Options", "オプション"), + ("Show Tool Tips", "ツールヒントを表示"), + ("Create Layers at Composition Start Time", "レイヤーをコンポジションの開始時間に作成"), + ("Switches Affect Nested Comps", "ネストしたコンポジションにスイッチを適用"), + ("Default Spatial Interpolation to Linear", "初期設定の空間補間法にリニアを使用"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "マスク編集時に一定の頂点数とぼかしポイント数を保持"), + ("Synchronize Time of All Related Items", "すべての関連アイテムの時間を同期"), + ("Expression Pick Whip Writes Compact English", "ピックウィップによる簡潔な英語でのエクスプレッションの記述"), + ("Create Split Layers Above Original Layer", "元のレイヤーの上に分割したレイヤーを作成"), + ("Use System Color Picker", "システムのカラーピッカーを使用"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraftは常に独自のカラーピッカーを使用します(すべてのプラットフォームとWeb版で同じです)。", + ), + ("Startup & Repair", "起動と修復"), + ("Show Home Screen When Launching", "起動時にホーム画面を表示"), + ("Show Home Screen When Opening a Project", "プロジェクトを開くときにホーム画面を表示"), + ("Offer to Open the Latest Auto-Save After a Crash", "クラッシュ後に最新の自動保存を開くか確認する"), + ("Window Graphics", "ウィンドウグラフィックス"), + ("Automatic", "自動"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "ウィンドウグラフィックスは次回の起動から適用されます。ウィンドウを開くときにグラフィックスドライバーがEffectCraftを停止させた場合、次回の起動ではOpenGLに切り替わります。", + ), + ("Repair", "修復"), + ("Reset Settings", "設定をリセット"), + ("Reset Keyboard Shortcuts", "キーボードショートカットをリセット"), + ("Empty Disk Cache", "ディスクキャッシュを空にする"), + ("New Project Loads Template", "新規プロジェクト読み込みテンプレート"), + ("Template Project", "テンプレートプロジェクト"), + ("Auto-Save", "自動保存"), + ("Automatically Save Projects", "プロジェクトを自動保存"), + ("Save Every", "保存の間隔"), + ("Minutes", "分"), + ("Maximum Project Versions", "プロジェクトバージョンの最大数"), + ("Auto-Save Location", "自動保存の場所"), + ("Next to Project", "プロジェクトの横"), + ("Custom Location", "ユーザー定義の場所"), + ("Save When Starting Render Queue", "レンダーキューの開始時に保存"), + ("Save Now", "今すぐ保存"), + ("Motion Path", "モーションパス"), + ("All Keyframes", "すべてのキーフレーム"), + ("No Motion Path", "モーションパスなし"), + ("No More Than (Seconds)", "次の秒数以下"), + ("No More Than (Keyframes)", "次のキーフレーム以下"), + ("Seconds", "秒"), + ("Keyframes", "キーフレーム"), + ("Display", "表示"), + ("Show Rendering Progress in Info Panel and Flowchart", "情報パネルとフローチャートにレンダリングの進行状況を表示"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "ハードウェアによるコンポジション、レイヤー、フッテージパネルの高速化"), + ("Panels are always drawn by the GPU (wgpu).", "パネルは常にGPU(wgpu)で描画されます。"), + ("Previews", "プレビュー"), + ("Adaptive Resolution Limit", "適応解像度の上限"), + ("Show Internal Wireframes", "内部ワイヤーフレームを表示"), + ("Cache Frames When Idle", "アイドル時にフレームをキャッシュ"), + ("Viewer Zoom Quality", "ズームの画質"), + ("Faster", "高速"), + ("More Accurate", "精度を優先"), + ("Display Color Space", "表示色空間"), + ("GPU Information...", "GPU 情報..."), + ("Appearance", "外観"), + ("Brightness", "明るさ"), + ("UI Scale", "UI スケール"), + ("Labels and Colors", "ラベルとカラー"), + ("Use Label Color for Layer Handles and Paths", "レイヤーハンドルとパスにラベルカラーを使用"), + ("Use Label Color for Related Tabs", "ラベルカラーを関連するタブに使用"), + ("Cycle Mask Colors", "新規マスクに別のカラーを使用"), + ("Use Gradients", "グラデーションを使用"), + ("Menu Bar", "メニューバー"), + ("Grids & Guides", "グリッド&ガイド"), + ("Gridline Every", "グリッドの間隔"), + ("Subdivisions", "分割数"), + ("Style", "スタイル"), + ("Lines", "線"), + ("Dashed Lines", "破線"), + ("Dots", "ドット"), + ("Horizontal", "水平方向"), + ("Vertical", "垂直方向"), + ("Safe Margins", "セーフマージン"), + ("Action-safe", "アクションセーフ"), + ("Title-safe", "タイトルセーフ"), + ("Labels", "ラベル"), + ("Text Engine", "テキストエンジン"), + ("Latin", "ラテン"), + ("South Asian and Middle Eastern", "南アジアおよび中東"), + ("Show Font Preview", "フォントのプレビューを表示"), + ("Number of Recent Fonts to Display", "最近使用したフォントの表示数"), + ("Show Font Names in English", "フォント名を英語で表示"), + ("Still Footage", "静止画フッテージ"), + ("Length of Composition", "コンポジションの長さ"), + ("Duration (Seconds)", "デュレーション(秒)"), + ("Still Duration", "静止画のデュレーション"), + ("Sequence Footage", "シーケンスフッテージ"), + ("Report Missing Frames", "不明なフレームをレポート"), + ("Automatically Reload Footage", "フッテージの自動再読み込み"), + ("Off", "オフ"), + ("Non-Sequence Footage", "非シーケンスフッテージ"), + ("All Footage", "すべてのフッテージ"), + ("Ask User", "ユーザーに確認"), + ("Guess", "推測"), + ("Ignore", "無視"), + ("Straight", "そのまま"), + ("Premultiplied", "プリマルチプライ"), + ("Default Drag Import As", "複数アイテムのドラッグ読み込みの初期設定"), + ("Footage", "フッテージ"), + ("Composition - Retain Layer Sizes", "コンポジション - レイヤーサイズを保持"), + ("Export", "書き出し"), + ("Default Output Folder", "既定の出力フォルダー"), + ("Segment Sequences", "シーケンスをセグメント化"), + ("Files per Segment", "セグメントあたりのファイル数"), + ("files", "ファイル"), + ("Segment Movie Files", "ムービーファイルをセグメント化"), + ("Segment Size", "セグメントサイズ"), + ("Append Bit Depth to File Name", "ファイル名にビット深度を追加"), + ("Audio", "オーディオ"), + ("Audio Hardware", "オーディオハードウェア"), + ("Preview Sample Rate", "サンプルレート"), + ("Audio Output Mapping", "オーディオ出力マッピング"), + ("Left", "左"), + ("Right", "右"), + ("channel", "チャンネル"), + ("Disk", "ディスク"), + ("Disk Cache", "ディスクキャッシュ"), + ("Enable Disk Cache", "ディスクキャッシュを有効にする"), + ("Maximum Disk Cache Size", "最大ディスクキャッシュサイズ"), + ("Disk Cache Folder", "ディスクキャッシュフォルダー"), + ("Media Cache", "メディアキャッシュ"), + ("Database and Cache Folder", "データベースとキャッシュのフォルダー"), + ("Conformed Audio Folder", "適合オーディオフォルダー"), + ("Memory & CPU", "メモリと CPU"), + ("RAM Reserved for Other Applications", "他のアプリケーション用に確保する RAM"), + ("Cache Budgets", "キャッシュの割り当て"), + ("Layer Cache", "レイヤーキャッシュ"), + ("Footage Frame Cache", "フッテージフレームキャッシュ"), + ("Preview (RAM) Cache", "プレビュー(RAM)キャッシュ"), + ("Reduce Cache Size When System Is Low on Memory", "システムのメモリが不足しているときにキャッシュサイズを減らす"), + ("Video", "ビデオ"), + ("Enable Video Preview Output", "ビデオプレビュー出力を有効にする"), + ("Video Device", "ビデオデバイス"), + ("Floating Window", "フローティングウィンドウ"), + ("Full Screen", "全画面"), + ("Video Output During Playback", "再生中のビデオ出力"), + ("Mirror on Computer Monitor", "コンピューターモニターにミラーリング"), + ("Disable Video Output When in Background", "バックグラウンド動作時のビデオ出力を無効にする"), + ("Default 3D Renderer", "既定の 3D レンダラー"), + ("Show 3D Reference Axes", "3D参照軸を表示"), + ("Extended Viewer", "拡張ビューアー"), + ("Realtime Shadows in Draft", "ドラフトのリアルタイムシャドウ"), + ("Scripting & Expressions", "スクリプトとエクスプレッション"), + ("Application Scripting", "アプリケーションのスクリプト"), + ("Allow Scripts to Write Files and Access Network", "スクリプトによるファイルへの書き込みとネットワークへのアクセスを許可"), + ("Warn User When Executing Files", "ファイルの実行時にカーソルに警告"), + ("Enable JavaScript Debugger", "JavaScript デバッガーを使用"), + ("Expressions Editor", "エクスプレッションエディター"), + ("Font Size", "フォントサイズ"), + ("Syntax Highlighting", "構文のハイライト"), + ("Line Numbers", "行番号を表示"), + ("Auto-complete", "オートコンプリート"), + ("Bracket Matching", "対応する括弧をハイライト"), + ("Word Wrap", "折り返し"), + ("Show Expression Error Banner", "式のエラーがある場合に警告バナーを表示"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "エクスプレッションはJavaScriptエンジンを使用します。スクリプトのエラーはファイルと行をスクリプトコンソールに報告します。ステップ実行デバッガーはありません。", + ), + ("Roto Brush", "ロトブラシ"), + ("Segmentation Model", "セグメンテーションモデル"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "ロトブラシ2.0と3.0はここで選んだモデルを使用します(バージョン1.0は常に従来のエンジンを使用します)。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。", + ), + ("Face Tracking", "フェイストラッキング"), + ("Face Model", "フェイスモデル"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "トラッカーパネルのフェイストラッキング(輪郭のみと詳細機能)はここで選んだモデルを使用します。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。", + ), + ("Match System", "システムに合わせる"), ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), From eb95f732e5ae2aabda0443ec941173dc2960d9bc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:33:49 -0400 Subject: [PATCH 069/185] Media: millisecond-rounded MP4 frame times read at the rate they average to (#1442) Phones and messaging apps store frame times rounded to whole milliseconds (33/34 ms at 30 fps, 16/17 ms at 60 fps). FilmCraft's reader takes the median duration, so such clips imported at 30.303 or 58.824 fps, and New Comp from Selection and renders followed that rate. When the median is no standard rate, the durations near it are averaged and snapped to the standard rate within 0.5 %; exact and variable rates keep the reader's estimate. --- crates/media/src/probe.rs | 54 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index bae714c8..dad34fd1 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -19,8 +19,10 @@ pub fn probe(path: impl AsRef) -> Result { // A movie is read in place (its index), not loaded whole. if let Some(src) = crate::stream::probe_source(path) { let mut f = footage_from_info(&path.to_string_lossy(), src?.info()); - let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h)); + let header = crate::stream::movie_header(path); + let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn); turn_footage(&mut f, turn); + correct_rate(&mut f, header.as_deref().and_then(video_rate)); return Ok(f); } let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; @@ -95,12 +97,24 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { } let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); + let rate = video_rate(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); turn_footage(&mut f, turn); + correct_rate(&mut f, rate); Ok(f) } +/// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's +/// own estimate misreads it. +fn correct_rate(f: &mut Footage, rate: Option) { + if f.kind == FootageKind::Video + && let Some(rate) = rate + { + f.frame_rate = rate; + } +} + /// The size as displayed: a movie shot with the camera on its side trades width and height. fn turn_footage(f: &mut Footage, turn: Orientation) { if swaps_sides(turn) { @@ -254,3 +268,41 @@ pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { (false, _, false) => Orientation::Rotate270, } } + +/// The frame rate of an MP4/MOV movie's video track when the reader's own estimate, the median +/// frame duration, misreads it. Phones and messaging apps store frame times rounded to whole +/// milliseconds: a 30 fps clip's frames last 33 or 34 ms and a 60 fps clip's 16 or 17 ms, whose +/// median reads as 30.303 or 58.824 fps. `None` for other containers and for movies the median +/// reads right. +fn video_rate(bytes: &[u8]) -> Option { + let file = filmcraft_isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + averaged_rate(t.timescale, t.samples.iter().take(240).map(|s| s.duration)) +} + +/// The standard rate ([`FrameRate::COMMON`]) within 0.5 % of what the frame durations near their +/// median average to (`durations` in `1 / timescale` s); `None` when the median duration is a +/// standard rate already, or when the average is no standard rate (variable or unusual rates keep +/// the reader's estimate). +fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option { + let ts = i128::from(timescale); + let mut durs: Vec = durations.filter(|&d| d > 0).map(u64::from).collect(); + durs.sort_unstable(); + let median = *durs.get(durs.len() / 2)?; + if FrameRate::COMMON.iter().any(|r| ts * i128::from(r.den) == i128::from(median) * i128::from(r.num)) { + return None; + } + // Durations within a quarter of the median (widened, so extreme durations can't overflow). + let (n, sum) = durs.iter().filter(|&&d| d.abs_diff(median).saturating_mul(4) <= median).fold((0u64, 0u64), |(n, s), &d| (n + 1, s.saturating_add(d))); + if sum == 0 { + return None; + } + let fps = timescale as f64 * n as f64 / sum as f64; + FrameRate::COMMON + .into_iter() + .map(|r| (r, (fps / r.as_f64() - 1.0).abs())) + .filter(|&(_, off)| off <= 0.005) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(r, _)| r) +} + From 36a905293b65d4487528456cf6d308e89fd7283d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:33:49 -0400 Subject: [PATCH 070/185] Media: tests for the averaged MP4 frame rate (#1442) --- crates/media/src/probe.rs | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index dad34fd1..2793be25 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -306,3 +306,48 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option .map(|(r, _)| r) } +#[cfg(test)] +mod tests { + use super::*; + + /// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale. + fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec { + let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num); + (0..n).map(|k| (at(k + 1) - at(k)) as u32).collect() + } + + #[test] + fn millisecond_rounded_frame_times_average_to_their_rate() { + assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]); + assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_30, 1000, 120).into_iter()), Some(FrameRate::FPS_30)); + assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_60, 1000, 240).into_iter()), Some(FrameRate::FPS_60)); + // NTSC rates stay NTSC, not the round rate next to them. + for r in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] { + assert_eq!(averaged_rate(1000, rounded(r, 1000, 240).into_iter()), Some(r), "{r:?}"); + } + // A short clip: its few frames average to the rate as well. + assert_eq!(averaged_rate(1000, [33, 34, 33].into_iter()), Some(FrameRate::FPS_30)); + } + + #[test] + fn exact_and_variable_rates_keep_the_readers_estimate() { + // A median that is a standard rate already reads right. + assert_eq!(averaged_rate(30000, rounded(FrameRate::FPS_29_97, 30000, 240).into_iter()), None); + assert_eq!(averaged_rate(90000, [3000; 10].into_iter()), None); + assert_eq!(averaged_rate(600, [20, 20, 19, 21, 20].into_iter()), None); + // Durations averaging to no standard rate (22.2 fps). + assert_eq!(averaged_rate(1000, [40, 45, 50].repeat(20).into_iter()), None); + // A truly 30.3 fps clip is not pulled to 30 (1 % off). + assert_eq!(averaged_rate(1000, [33; 30].into_iter()), None); + } + + #[test] + fn degenerate_durations_have_no_rate() { + assert_eq!(averaged_rate(1000, std::iter::empty()), None); + assert_eq!(averaged_rate(1000, [0, 0, 0].into_iter()), None); + assert_eq!(averaged_rate(0, [33, 34, 33].into_iter()), None); + assert_eq!(averaged_rate(u32::MAX, [u32::MAX, u32::MAX - 1, 1, 0].into_iter()), None); + assert_eq!(averaged_rate(u32::MAX, [u32::MAX; 300].into_iter()), None); + assert_eq!(video_rate(b"not a movie"), None); + } +} From 8b536e597bab07116fb2776f23473549e868e329 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:37:36 -0400 Subject: [PATCH 071/185] Export: millisecond-rounded footage renders at its rate, each frame once (#1442) --- crates/export/tests/ms_rounded_footage.rs | 148 ++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 crates/export/tests/ms_rounded_footage.rs diff --git a/crates/export/tests/ms_rounded_footage.rs b/crates/export/tests/ms_rounded_footage.rs new file mode 100644 index 00000000..3d688023 --- /dev/null +++ b/crates/export/tests/ms_rounded_footage.rs @@ -0,0 +1,148 @@ +//! Footage whose frame times are rounded to whole milliseconds (#1442), as phones and messaging +//! apps write MP4s: a 30 fps clip's frames last 33 or 34 ms, a 60 fps clip's 16 or 17 ms. It +//! imports at its true rate, a comp made from it (New Comp from Selection takes the footage's +//! rate) renders every source frame once, and the H.264 output has that rate and frame count. +//! +//! The fixtures are written here with FilmCraft's own encoder and muxer: 128×32 frames showing +//! their index in binary (eight bars, white for a set bit). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::{Job, export}; +use effectcraft_media::MediaPool; +use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_time::{FrameRate, Tick}; +use filmcraft_isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + +const W: u32 = 128; +const H: u32 = 32; +const BARS: u32 = 8; + +fn out_dir(name: &str) -> PathBuf { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("mkdir"); + d +} + +/// Durations of `n` frames at `rate` with their times rounded to whole milliseconds. +fn ms_rounded(rate: FrameRate, n: i64) -> Vec { + let at = |k: i64| (2 * k * 1000 * rate.den + rate.num) / (2 * rate.num); + (0..n).map(|k| u32::try_from(at(k + 1) - at(k)).expect("duration")).collect() +} + +/// An H.264 MP4 (video track timescale 1000) with one frame per entry of `durations` (ms); +/// frame k shows k in binary. +fn write_movie(path: &Path, durations: &[u32]) { + let mut cfg = filmcraft_h264enc::EncoderConfig::new(W, H, 30, 1); + cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed; + cfg.aud = false; + cfg.bframes = 0; + cfg.keyint = 30; + cfg.scenecut = false; + let mut enc = filmcraft_h264enc::Encoder::new(cfg).expect("encoder"); + let (u, v) = (vec![128u8; (W * H / 4) as usize], vec![128u8; (W * H / 4) as usize]); + let mut packets = Vec::new(); + for k in 0..durations.len() { + let y: Vec = (0..W * H).map(|i| if (k >> (i % W * BARS / W)) & 1 == 1 { 235 } else { 16 }).collect(); + let frame = filmcraft_h264enc::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 }; + packets.extend(enc.try_encode(&frame, k as i64).expect("encode")); + } + packets.extend(enc.flush()); + assert_eq!(packets.len(), durations.len(), "one access unit per frame"); + let avcc = AvcConfig::parse(&enc.avcc()).expect("avcC"); + let file = std::fs::File::create(path).expect("create"); + let mut mux = Mp4Writer::new(file, WriterOptions::new(Brand::Mp4)).expect("mux"); + let t = mux.add_track(TrackConfig::new(SampleEntry::avc(avcc, W as u16, H as u16), 1000)).expect("track"); + for (p, &duration) in packets.iter().zip(durations) { + mux.write_sample(t, WriteSample { data: &p.data, duration, composition_offset: 0, is_sync: p.keyframe }).expect("sample"); + } + mux.finish().expect("finish"); +} + +/// The frame index a decoded frame shows. +fn shown(img: &Image) -> u32 { + (0..BARS).filter(|b| img.data[img.idx(b * W / BARS + W / BARS / 2, H / 2)][1] > 0.5).map(|b| 1 << b).sum() +} + +fn frame_count(f: &Footage) -> i64 { + f.frame_rate.frame_at(f.duration - Tick(1)) + 1 +} + +/// Every frame of a movie in order, as indices. +fn frames_of(f: &Footage) -> Vec { + let pool = MediaPool::new(); + (0..frame_count(f)).map(|k| shown(&pool.frame_at(f, f.frame_rate.tick_of(k)).expect("decode"))).collect() +} + +/// Import `path`, make a comp from it as New Comp from Selection does (the footage's size, rate +/// and duration), render it to H.264 and return the output's footage. +fn comp_from_footage(path: &Path, out: &Path) -> Footage { + let footage = effectcraft_media::probe(path).expect("probe"); + let mut p = Project::default(); + let fid = p.add_item("clip.mp4", Label::Aqua, None, ItemKind::Footage(footage.clone())); + let rate = p.item(fid).and_then(|it| it.frame_rate()).expect("a movie has a rate"); + let mut comp = Comp::new(W, H, rate, footage.duration); + comp.layers.push(build::layer(&mut p, &comp, "clip.mp4", LayerSource::Footage { item: fid }, (W, H), None)); + let cid = p.add_item("clip", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let pool = MediaPool::new(); + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) }; + let out_path = out.to_string_lossy().to_string(); + let job = Job { + project: &p, + footage: &pool, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: &out_path, + sink: None, + options: Default::default(), + nested_switches: true, + }; + let r = export(&job, &mut |_| true).expect("export"); + assert_eq!(r.frames as i64, frame_count(&footage), "the render has the footage's frames"); + effectcraft_media::probe(out).expect("probe the render") +} + +#[test] +fn millisecond_rounded_footage_keeps_its_rate_through_comp_and_render() { + let d = out_dir("ms_rounded"); + for (rate, frames) in [(FrameRate::FPS_30, 120), (FrameRate::FPS_60, 240)] { + let path = d.join(format!("ms-{}.mp4", rate.num)); + write_movie(&path, &ms_rounded(rate, frames)); + let f = effectcraft_media::probe(&path).expect("probe"); + assert_eq!((f.frame_rate, frame_count(&f)), (rate, frames), "{rate:?} footage"); + let bytes: Arc<[u8]> = std::fs::read(&path).expect("read").into(); + let from_bytes = effectcraft_media::probe_bytes(&path.to_string_lossy(), bytes).expect("probe bytes"); + assert_eq!(from_bytes.frame_rate, rate, "{rate:?} footage read from memory"); + let all: Vec = (0..frames as u32).collect(); + assert_eq!(frames_of(&f), all, "{rate:?} footage: each source frame once"); + + let out = comp_from_footage(&path, &d.join(format!("render-{}.mp4", rate.num))); + assert_eq!((out.frame_rate, frame_count(&out)), (rate, frames), "{rate:?} render"); + assert_eq!(out.duration, Tick::from_seconds_f64(4.0), "{rate:?} render duration"); + assert_eq!(frames_of(&out), all, "{rate:?} render: no frame skipped or repeated"); + } +} + +#[test] +fn ntsc_and_variable_rate_footage_is_not_pulled_to_a_round_rate() { + let d = out_dir("ms_rounded_controls"); + for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] { + let path = d.join(format!("ms-{}.mp4", rate.num)); + write_movie(&path, &ms_rounded(rate, 60)); + assert_eq!(effectcraft_media::probe(&path).expect("probe").frame_rate, rate); + } + // Frames of 40, 45 and 50 ms: the reader's median (45 ms, 22.2 fps) stands. + let path = d.join("variable.mp4"); + write_movie(&path, &[40, 45, 50].repeat(20)); + let f = effectcraft_media::probe(&path).expect("probe"); + assert!(!FrameRate::COMMON.contains(&f.frame_rate) && (f.frame_rate.as_f64() - 1000.0 / 45.0).abs() < 0.01, "{:?}", f.frame_rate); +} From ba37283aa4266af6555855fb82ef1dc538c90e85 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:14:18 +0700 Subject: [PATCH 072/185] fix(lottie): serialize Offset Paths miter limits as animatable values --- crates/lottie/src/shapes.rs | 6 ++--- crates/lottie/src/tests.rs | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index d7e3cc08..1283a249 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -308,7 +308,7 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve "op" => { o.put("a", "amount", &anim::num); o.raw("lj", json!(o.e("join") + 1)); - o.raw("ml", json!(o.f("miter"))); + o.put("ml", "miter", &anim::num); if (o.f("copies") - 1.0).abs() > 1e-9 || o.animated("copies") || o.f("copyOffset") != 0.0 { o.ex.warn(format!("{lbl}: Offset Paths copies are not part of Lottie (one copy exported)")); } @@ -543,9 +543,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec set(im, &mut g, "amount", j.get("a"), &anim::to_scalar), "tw" => { diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index a677def1..b4638cb8 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -358,6 +358,51 @@ fn path_operators_roundtrip() { compare_renders(&p, cid, &q, nc, 2.0 / 255.0); } +#[test] +fn offset_paths_miter_uses_animatable_lottie_value() { + let (mut p, cid) = setup(); + let l = shape_layer(&mut p, cid, |ids| { + let mut animated = build::shape_simple_op(ids, "offset").unwrap(); + animated.get_mut("miter").unwrap().keys = vec![ + Keyframe::new(s(0.0), Value::Scalar(5.0)), + Keyframe::new(s(1.0), Value::Scalar(9.0)), + ]; + let mut constant = build::shape_simple_op(ids, "offset").unwrap(); + constant.get_mut("miter").unwrap().value = Value::Scalar(6.0); + let mut stroke = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0); + stroke.get_mut("miter").unwrap().value = Value::Scalar(7.0); + vec![animated, constant, stroke] + }); + push(&mut p, cid, l); + + let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert!(warnings.is_empty(), "{warnings:?}"); + let shapes = &json["layers"][0]["shapes"]; + assert_eq!(shapes[0]["ty"], "op"); + assert_eq!(shapes[0]["ml"]["a"], json!(1)); + assert_eq!(shapes[0]["ml"]["k"][0]["s"], json!([5.0])); + assert_eq!(shapes[0]["ml"]["k"][1]["s"], json!([9.0])); + assert_eq!(shapes[1]["ml"], json!({"a": 0, "k": 6.0})); + assert_eq!(shapes[2]["ty"], "st"); + assert_eq!(shapes[2]["ml"], json!(7.0)); + assert!(compare_comps(&p, cid, &q, nc) > 10); + let restored = &q.comp(nc).unwrap().layers[0]; + assert_eq!(restored.props.group("contents/offset").unwrap().get("miter").unwrap().keys.len(), 2); + + // Previously exported Offset Paths have a scalar ml; they remain importable. + let mut old = json; + old["layers"][0]["shapes"][1]["ml"] = json!(6.0); + let mut legacy = Project::default(); + let bytes = serde_json::to_vec(&old).unwrap(); + let imported = import(&mut legacy, &bytes, "Legacy", "", &mut |_| None).unwrap(); + let constant = &legacy.comp(imported.comp).unwrap().layers[0].props; + let second = constant.sub("contents").unwrap().children.iter().filter_map(|n| match n { + Node::Group(g) if g.match_id == "offset" => Some(g), + _ => None, + }).nth(1).unwrap(); + assert_eq!(second.get("miter").unwrap().value, Value::Scalar(6.0)); +} + // ---------------------------------------------------------------- layers #[test] From a213b7f23a19a5cfe7226905cd08467afca72ac8 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 14 Oct 2026 21:21:39 +0500 Subject: [PATCH 073/185] i18n (de): fix two labels that name the wrong thing, settle six terms (#1443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two German menu labels described a different command: - `layer.timeReverse` was "Ebenenreihenfolge umkehren", which reads as reversing the stacking order rather than time -- now "Ebene zeitlich umkehren", matching `keys.timeReverse`; - `layer.style.convertToEditable` was "In editierbare Formate umwandeln", and "Formate" are formats, not layer styles -- now "In bearbeitbare Stile umwandeln". Where the same English term had more than one German rendering, the one already in the majority wins, so this settles rather than re-translates: - "Expression" is "Ausdruck" in seven entries and "Expressions" in the two `layer.expressions` ones, which now read "Ausdrücke aktivieren/deaktivieren"; - "Reveal" is "anzeigen" in eleven of twelve entries, so `layer.revealInFinder` joins `file.revealInFinder` at "Im Finder anzeigen" -- in every other catalogue both commands already share one string; - "Track" as a verb is "verfolgen" in the other four entries, so `track.mask` is "Maske verfolgen" rather than "Maske tracken"; - one spelling of "nach vorne", matching the neighbouring arrange entry. German also had no platform variants for the file manager. The engine puts the platform's name into the label before the catalogue sees it, so on Windows the source is "Reveal in Explorer" and on Linux "Reveal in File Manager"; only Ukrainian and Czech special-cased those, and a German UI showed the English string. Added, with a test mirroring `czech_…` and `ukrainian_…`. Two of the reported six are left alone: "Essential Graphics" is a 3-3 split and a panel's proper name, and "Null" partly mirrors the English, which itself says both "Null Object" and "Nulls". Both are translator's choices with no majority to follow, not corrections. --- crates/ui-egui/src/i18n.rs | 74 ++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4caae8dd..9f9f9ae3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -111,6 +111,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "de" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Im Explorer anzeigen", + "Reveal in File Manager" => return "Im Dateimanager anzeigen", + _ => {} + } + } if language == "cs" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { match source { "Reveal in Explorer" => return "Zobrazit v Průzkumníku", @@ -5555,7 +5562,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.settings", "Layer Settings...", "Ebeneneinstellungen..."), ("layer.openLayer", "Open Layer", "Ebene öffnen"), ("layer.openSource", "Open Layer Source", "Ebenenquelle öffnen"), - ("layer.revealInFinder", "Reveal in Finder", "Im Finder einblenden"), + ("layer.revealInFinder", "Reveal in Finder", "Im Finder anzeigen"), ("", "Mask", "Maske"), ("layer.addMask", "New Mask", "Neue Maske"), ("layer.mask.shape", "Mask Shape...", "Maskenform..."), @@ -5565,7 +5572,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.mask.reset", "Reset Mask", "Maske zurücksetzen"), ("layer.mask.remove", "Remove Mask", "Maske entfernen"), ("layer.mask.removeAll", "Remove All Masks", "Alle Masken entfernen"), - ("track.mask", "Track Mask", "Maske tracken"), + ("track.mask", "Track Mask", "Maske verfolgen"), ("", "Mode", "Modus"), ("layer.mask.mode", "None", "Ohne"), ("layer.mask.mode", "Add", "Addieren"), @@ -5602,8 +5609,8 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.hideOtherVideo", "Hide Other Video", "Anderes Video ausblenden"), ("layer.showAllVideo", "Show All Video", "Gesamtes Video einblenden"), ("layer.unlockAll", "Unlock All Layers", "Alle Ebenen entsperren"), - ("layer.expressions", "Enable Expressions", "Expressions aktivieren"), - ("layer.expressions", "Disable Expressions", "Expressions deaktivieren"), + ("layer.expressions", "Enable Expressions", "Ausdrücke aktivieren"), + ("layer.expressions", "Disable Expressions", "Ausdrücke deaktivieren"), ("layer.setSwitch", "Shy", "Schüchtern"), ("layer.setSwitch", "Lock", "Sperren"), ("layer.setSwitch", "Audio", "Audio"), @@ -5631,7 +5638,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.autoOrient", "Auto-Orient...", "Automatische Ausrichtung..."), ("", "Time", "Zeit"), ("layer.enableTimeRemap", "Enable Time Remapping", "Zeitverzerrung aktivieren"), - ("layer.timeReverse", "Time-Reverse Layer", "Ebenenreihenfolge umkehren"), + ("layer.timeReverse", "Time-Reverse Layer", "Ebene zeitlich umkehren"), ("layer.timeStretch", "Time Stretch...", "Zeitdehnung..."), ("layer.freezeFrame", "Freeze Frame", "Frame fixieren"), ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Bei letztem Frame fixieren"), @@ -5700,7 +5707,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.trackMatte", "Matte with Layer Below", "Spurmaske mit Ebene darunter"), ("", "Layer Styles", "Ebenenstile"), ("layer.style.options", "Layer Style Options...", "Ebenenstiloptionen..."), - ("layer.style.convertToEditable", "Convert to Editable Styles", "In editierbare Formate umwandeln"), + ("layer.style.convertToEditable", "Convert to Editable Styles", "In bearbeitbare Stile umwandeln"), ("layer.style.showAll", "Show All", "Alle anzeigen"), ("layer.style.removeAll", "Remove All", "Alle entfernen"), ("layer.style.dropShadow", "Drop Shadow", "Schlagschatten"), @@ -5715,7 +5722,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("path.groupShapes", "Group Shapes", "Formen gruppieren"), ("path.ungroupShapes", "Ungroup Shapes", "Gruppierung von Formen aufheben"), ("", "Arrange", "Anordnen"), - ("layer.arrange", "Bring Layer to Front", "Ebene ganz nach vorn"), + ("layer.arrange", "Bring Layer to Front", "Ebene ganz nach vorne"), ("layer.arrange", "Bring Layer Forward", "Ebene nach vorne"), ("layer.arrange", "Send Layer Backward", "Ebene nach hinten"), ("layer.arrange", "Send Layer to Back", "Ebene ganz nach hinten"), @@ -6679,6 +6686,59 @@ mod tests { assert_eq!(entry(&app, &redo, "Redo Text Čeština".into()), "Znovu Text Čeština"); } + #[test] + fn german_reveal_labels_follow_the_platform_file_manager() { + // As `czech_…` and `ukrainian_…` do. Asserted through `label`, not `entry`: `entry` + // returns the English for German, Russian and French, which have catalogues but are + // not wired into the menus (see the undo/redo match in `entry` and `supported`). + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "de"})).unwrap(); + for command in ["file.revealInFinder", "layer.revealInFinder"] { + for (source, translated) in [ + ("Reveal in Finder", "Im Finder anzeigen"), + ("Reveal in Explorer", "Im Explorer anzeigen"), + ("Reveal in File Manager", "Im Dateimanager anzeigen"), + ] { + assert_eq!(label(&app, command, source), translated, "{command} / {source}"); + // Another command keeps a label that merely looks like one of these. + assert_eq!(label(&app, "file.openRecent", source), source); + } + assert_eq!(label(&app, command, "My file.mp4"), "My file.mp4", "a user's filename is never looked up"); + } + } + + #[test] + fn german_menu_labels_name_what_their_command_does() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "de"})).unwrap(); + // "Ebenenreihenfolge umkehren" reads as reversing the stacking order, not time, and + // "Formate" are formats, not layer styles (#1443). + assert_eq!(label(&app, "layer.timeReverse", "Time-Reverse Layer"), "Ebene zeitlich umkehren"); + assert_eq!(label(&app, "keys.timeReverse", "Time-Reverse Keyframes"), "Keyframes zeitlich umkehren"); + assert_eq!(label(&app, "layer.style.convertToEditable", "Convert to Editable Styles"), "In bearbeitbare Stile umwandeln"); + } + + #[test] + fn german_uses_one_word_per_term() { + let german: Vec<&str> = GERMAN.iter().map(|(_, _, de)| *de).collect(); + let with = |needle: &str| german.iter().filter(|de| de.contains(needle)).copied().collect::>(); + // "Expression" is "Ausdruck", not the English word. + assert!(with("Expression").is_empty(), "{:?}", with("Expression")); + // "Track" as a verb is "verfolgen", not "tracken". + assert!(with("tracken").is_empty(), "{:?}", with("tracken")); + // "Reveal " is "anzeigen". ("einblenden" stays right for "Show All", + // "Fade from Blur" and the like, so only the Reveal entries are checked.) + let reveal: Vec<&str> = + GERMAN.iter().filter(|(_, en, _)| en.starts_with("Reveal")).map(|(_, _, de)| *de).filter(|de| !de.to_lowercase().contains("anzeigen")).collect(); + assert!(reveal.is_empty(), "{reveal:?}"); + // One spelling of "to the front". + let vorn: Vec<&str> = german.iter().filter(|de| de.ends_with("nach vorn")).copied().collect(); + assert!(vorn.is_empty(), "{vorn:?}"); + // The terms those entries joined are still the majority ones. + assert!(with("verfolgen").len() >= 4, "{:?}", with("verfolgen")); + assert!(with("anzeigen").len() >= 12, "{}", with("anzeigen").len()); + } + #[test] fn czech_reveal_labels_follow_the_platform_file_manager() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); From 6ad540e8566384e01cd7b988802808244f4d54fb Mon Sep 17 00:00:00 2001 From: tydude001 Date: Wed, 14 Oct 2026 11:30:14 -0500 Subject: [PATCH 074/185] packaging: RPM requires library sonames, so openSUSE can install it EffectCraft's RPM requires Fedora package names: `libxkbcommon`, `(vulkan-loader or libglvnd-egl)`, `alsa-lib`. openSUSE has no `libxkbcommon`, `vulkan-loader` or `libglvnd-egl` package, so zypper refuses it with `nothing provides 'libxkbcommon'` (exit 4, which the ArtCraft Launcher shows as "error 4"; storytold/craft-launcher#341). Co-Authored-By: Claude Opus 5.5 --- packaging/linux/nfpm.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 76d41450..ec961215 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -44,10 +44,17 @@ overrides: - mesa-vulkan-drivers - xdg-desktop-portal rpm: + # Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL + # and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package). The glibc + # floor names libm because the binaries' GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's + # glibc 2.34 provides libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses. depends: - - libxkbcommon - - (vulkan-loader or libglvnd-egl) - - alsa-lib + - libc.so.6()(64bit) + - libm.so.6(GLIBC_2.35)(64bit) + - libgcc_s.so.1()(64bit) + - libxkbcommon.so.0()(64bit) + - (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit)) + - libasound.so.2()(64bit) recommends: - libwayland-client - libX11 From 2f437ea551de3b8f512d28f10a46fe58a509ed78 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 09:38:47 -0700 Subject: [PATCH 075/185] EPS and PDF: huge arcs, long paths and loop passes count against the import's limits The EPS interpreter counted one operation against its budget for each name it ran, whatever the operator did, and paths had no length limit. Parsing one arc at a radius of 10^300 passed 3 GiB of resident memory within 43 seconds. A loop of arc at a radius of 2.2 x 10^18 was still running after 60 seconds, and a loop of pathbbox on a path of 100,001 elements and { {} loop } loop did not finish within 20 seconds. PDF content had no path limit either: parsing a page that repeats 0 0 1 1 re 4,000,000 times peaked at 2.3 GiB of resident memory. A path now holds at most 1,048,576 elements (MAX_PATH, the limit of VectorCraft's EPS interpreter). In EPS, moveto, lineto, curveto, closepath, arc, charpath and the rect operators check it, and an operator that would take a path past it stops the program with "PostScript (path limit)". In PDF, m, l, c, v, y, h and re (each adds at most five elements) stop the content stream with "content (path limit)" when the path already holds more than 1,048,571. Each path element an operator makes, reads or copies (arc, rectangle arrays, charpath, pathbbox, clip, eoclip, gsave, save) now counts against the EPS budget. Each pass of repeat, for, loop and forall counts too, and so does each procedure item that is not a name. A moveto right after a moveto replaces it, as the PostScript Language Reference describes, so text placed with moveto and show leaves one element. Five tests in tests_import.rs cover the loops, the path limit in both interpreters and runs of moveto and show. --- crates/pdf/src/build.rs | 4 + crates/pdf/src/page.rs | 9 +- crates/pdf/src/ps.rs | 174 +++++++++++++++++++++++---------- crates/pdf/src/tests_import.rs | 94 ++++++++++++++++++ 4 files changed, 228 insertions(+), 53 deletions(-) diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 7023e931..0b1586f8 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -3,6 +3,10 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke}; +/// The most elements a path being built holds: an operator that would add more stops the +/// PostScript program, or the PDF content stream. +pub(crate) const MAX_PATH: usize = 1 << 20; + #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Plain, diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 13e98b3c..1cf620f1 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -12,7 +12,7 @@ use effectcraft_svg::{ }; use kurbo::{Point, Shape as _}; -use crate::build::Builder; +use crate::build::{Builder, MAX_PATH}; use crate::color::{Cs, Func, color_space, text_string}; use crate::font::Font; use crate::object::{Dict, File, Lexer, Obj, decode_stream}; @@ -281,7 +281,12 @@ impl<'a> Interp<'a> { self.ext_gstate(&g, res, depth); } } - // Path construction. + // Path construction. Each operator adds at most five elements (`re`), so when fewer + // than five more fit within `MAX_PATH`, the operator stops the stream. + "m" | "l" | "c" | "v" | "y" | "h" | "re" if self.path.elements().len() > MAX_PATH - 5 => { + self.b.skip("content (path limit)"); + return; + } "m" => { let p = Point::new(n(0), n(1)); self.path.move_to(p); diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 65e352cb..93ed55e7 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -14,7 +14,7 @@ use std::rc::Rc; use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke}; use kurbo::{PathEl, Point}; -use crate::build::Builder; +use crate::build::{Builder, MAX_PATH}; use crate::font::Font; use crate::object::{ascii85, is_white, parse_num}; @@ -441,7 +441,14 @@ impl Interp { break; } } - other => self.push(other.clone()), + // Other items count against the budget as names do. + other => { + r = self.spend(1); + if r.is_err() { + break; + } + self.push(other.clone()); + } } } self.calls -= 1; @@ -456,11 +463,7 @@ impl Interp { } fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { - if self.budget == 0 { - self.b.skip("PostScript (operation limit)"); - return Err(Flow::Quit); - } - self.budget -= 1; + self.spend(1)?; if let Some(v) = self.lookup(n) { return match v { V::Proc(_) => self.exec_obj(v, src), @@ -473,6 +476,21 @@ impl Interp { self.builtin(n, src) } + /// Count `n` operations against the budget, and stop the program when fewer are left. An + /// operator that makes, reads or copies path elements counts each one as an operation. + fn spend(&mut self, n: usize) -> Result<(), Flow> { + match self.budget.checked_sub(n) { + Some(left) => { + self.budget = left; + Ok(()) + } + None => { + self.b.skip("PostScript (operation limit)"); + Err(Flow::Quit) + } + } + } + fn device_scale(&self) -> f64 { self.gs.ctm.determinant().abs().sqrt() } @@ -483,13 +501,34 @@ impl Interp { Point::new(x, y) } - fn move_to(&mut self, p: Point) { + /// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`]. + fn path_fits(&mut self, len: usize) -> Result<(), Flow> { + if len <= MAX_PATH { + return Ok(()); + } + self.b.skip("PostScript (path limit)"); + Err(Flow::Quit) + } + + /// [`Self::path_fits`] for the current path with `n` more elements. + fn room(&mut self, n: usize) -> Result<(), Flow> { + self.path_fits(self.gs.path.elements().len().saturating_add(n)) + } + + fn move_to(&mut self, p: Point) -> Result<(), Flow> { + // A moveto right after a moveto replaces it (PostScript Language Reference §8.2, `moveto`). + if matches!(self.gs.path.elements().last(), Some(PathEl::MoveTo(_))) { + self.gs.path.pop(); + } + self.room(1)?; let q = self.gs.ctm * p; self.gs.path.move_to(q); self.gs.cur = Some(q); self.gs.start = Some(q); + Ok(()) } - fn line_to(&mut self, p: Point) { + fn line_to(&mut self, p: Point) -> Result<(), Flow> { + self.room(1 + usize::from(self.gs.cur.is_none()))?; let q = self.gs.ctm * p; if self.gs.cur.is_none() { self.gs.path.move_to(q); @@ -497,20 +536,23 @@ impl Interp { } self.gs.path.line_to(q); self.gs.cur = Some(q); + Ok(()) } - fn curve_to(&mut self, a: Point, b: Point, c: Point) { + fn curve_to(&mut self, a: Point, b: Point, c: Point) -> Result<(), Flow> { + self.room(1 + usize::from(self.gs.cur.is_none()))?; let m = self.gs.ctm; if self.gs.cur.is_none() { self.gs.path.move_to(m * a); } self.gs.path.curve_to(m * a, m * b, m * c); self.gs.cur = Some(m * c); + Ok(()) } fn current_user(&self) -> Point { self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default() } - fn arc(&mut self, neg: bool) { + fn arc(&mut self, neg: bool) -> Result<(), Flow> { let a2 = self.popn(); let a1 = self.popn(); let r = self.popn(); @@ -540,16 +582,23 @@ impl Interp { let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians()); let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()); if self.gs.cur.is_some() { - self.line_to(start); + self.line_to(start)?; } else { - self.move_to(start); + self.move_to(start)?; } let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0); let m = self.gs.ctm; - arc.to_cubic_beziers(0.1, |p1, p2, p3| { + // kurbo divides an arc into more curves the larger the radius (about 3.7 × 10^49 for a + // quarter turn at a radius of 10^300, more than a usize can count), so each one counts + // against the budget and the path limit. + let mut curves = arc.append_iter(0.1); + while let Some(PathEl::CurveTo(p1, p2, p3)) = curves.next() { + self.room(1)?; + self.spend(1)?; self.gs.path.curve_to(m * p1, m * p2, m * p3); self.gs.cur = Some(m * p3); - }); + } + Ok(()) } fn paint(&mut self, fill: Option, stroke: bool) { @@ -569,11 +618,16 @@ impl Interp { self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st); } - fn rect_path(&mut self) -> Vec { + fn rect_path(&mut self) -> Result, Flow> { // x y w h, or an array / string of numbers. let top = self.pop(); let nums: Vec = match &top { - V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(), + V::Array(_) | V::Proc(_) => { + let items = top.items().unwrap_or_default(); + // An array's rectangles count against the budget, five path elements each. + self.spend(items.len() / 4 * 5)?; + items.iter().filter_map(V::num).collect() + } V::Num(h) => { let w = self.popn(); let y = self.popn(); @@ -582,8 +636,10 @@ impl Interp { } _ => vec![], }; + self.path_fits(nums.len() / 4 * 5)?; let m = self.gs.ctm; - nums.as_chunks::<4>() + Ok(nums + .as_chunks::<4>() .0 .iter() .map(|r| { @@ -595,7 +651,7 @@ impl Interp { p.close_path(); p }) - .collect() + .collect()) } fn grestore(&mut self) { @@ -809,10 +865,12 @@ impl Interp { let c = matches!(self.pop(), V::Bool(true)); self.exec_obj(if c { t } else { f }, src)?; } + // Each pass of a loop counts against the budget. "repeat" => { let p = self.pop(); let k = self.popn().clamp(0.0, 1e6) as usize; for _ in 0..k { + self.spend(1)?; match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, r => r?, @@ -826,6 +884,7 @@ impl Interp { let mut i = self.popn(); let mut guard = 0; while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { + self.spend(1)?; self.push(V::Num(i)); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -838,6 +897,7 @@ impl Interp { "loop" => { let p = self.pop(); for _ in 0..1_000_000 { + self.spend(1)?; match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, r => r?, @@ -850,6 +910,7 @@ impl Interp { V::Dict(d) => { let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); for (k, v) in items { + self.spend(1)?; self.push(V::Lit(k.into())); self.push(v); match self.exec_obj(p.clone(), src) { @@ -861,6 +922,7 @@ impl Interp { V::Str(s) => { let bytes = s.borrow().clone(); for c in bytes { + self.spend(1)?; self.push(V::Num(c as f64)); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -870,6 +932,7 @@ impl Interp { } a => { for v in a.items().unwrap_or_default() { + self.spend(1)?; self.push(v); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -1127,6 +1190,7 @@ impl Interp { "languagelevel" => self.push(V::Num(3.0)), "version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))), "save" => { + self.spend(self.gs.path.elements().len())?; self.saved.push(self.gs.clone()); self.gs.depth = self.b.depth(); self.push(V::Null); @@ -1156,6 +1220,7 @@ impl Interp { } // Graphics state. "gsave" => { + self.spend(self.gs.path.elements().len())?; let mut g = self.gs.clone(); g.depth = self.b.depth(); self.saved.push(g); @@ -1316,21 +1381,21 @@ impl Interp { } "moveto" => { let p = self.user_point(); - self.move_to(p); + self.move_to(p)?; } "rmoveto" => { let d = self.user_point(); let c = self.current_user(); - self.move_to(Point::new(c.x + d.x, c.y + d.y)); + self.move_to(Point::new(c.x + d.x, c.y + d.y))?; } "lineto" => { let p = self.user_point(); - self.line_to(p); + self.line_to(p)?; } "rlineto" => { let d = self.user_point(); let c = self.current_user(); - self.line_to(Point::new(c.x + d.x, c.y + d.y)); + self.line_to(Point::new(c.x + d.x, c.y + d.y))?; } "curveto" | "rcurveto" => { let p3 = self.user_point(); @@ -1339,25 +1404,27 @@ impl Interp { if n == "rcurveto" { let c = self.current_user(); let o = |p: Point| Point::new(c.x + p.x, c.y + p.y); - self.curve_to(o(p1), o(p2), o(p3)); + self.curve_to(o(p1), o(p2), o(p3))?; } else { - self.curve_to(p1, p2, p3); + self.curve_to(p1, p2, p3)?; } } "closepath" => { if self.gs.cur.is_some() { + self.room(1)?; self.gs.path.close_path(); self.gs.cur = self.gs.start; } } - "arc" => self.arc(false), - "arcn" => self.arc(true), + "arc" => self.arc(false)?, + "arcn" => self.arc(true)?, "currentpoint" => { let c = self.current_user(); self.push(V::Num(c.x)); self.push(V::Num(c.y)); } "pathbbox" => { + self.spend(self.gs.path.elements().len())?; let r = kurbo::Shape::bounding_box(&self.gs.path); let inv = self.gs.ctm.inverse(); let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); @@ -1376,13 +1443,14 @@ impl Interp { "stroke" => self.paint(None, true), "clip" | "eoclip" => { let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd }; + self.spend(self.gs.path.elements().len())?; let p = self.gs.path.clone(); if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) { self.b.clip(p, rule); } } "rectfill" | "rectstroke" | "rectclip" => { - let paths = self.rect_path(); + let paths = self.rect_path()?; if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) { self.pop(); } @@ -1617,9 +1685,9 @@ impl Interp { /// A string's glyph outlines (device space) from the current point, with an extra /// displacement `extra(index, code)` (user space) after each glyph; moves the current /// point. - fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> { - let d = self.gs.font.clone()?; - let (font, m) = self.font_of(&d)?; + fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result, Flow> { + let Some(d) = self.gs.font.clone() else { return Ok(None) }; + let Some((font, m)) = self.font_of(&d) else { return Ok(None) }; let mut p = self.current_user(); let mut path = BezPath::new(); let mut text = String::new(); @@ -1634,8 +1702,8 @@ impl Interp { let (ex, ey) = extra(i, code); p += adv + kurbo::Vec2::new(ex, ey); } - self.move_to(p); - Some((path, text)) + self.move_to(p)?; + Ok(Some((path, text))) } fn fill_text(&mut self, path: BezPath, text: &str) { @@ -1651,11 +1719,12 @@ impl Interp { } } - fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) { - match self.text_path(s, extra) { + fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<(), Flow> { + match self.text_path(s, extra)? { Some((path, text)) => self.fill_text(path, &text), None => self.b.skip("text (no font)"), } + Ok(()) } /// Text and font operators (PostScript Language Reference §5.1, §8.2). @@ -1713,20 +1782,20 @@ impl Interp { "StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)), "show" => { let s = self.pop_string(); - self.show_with(&s, |_, _| (0.0, 0.0)); + self.show_with(&s, |_, _| (0.0, 0.0))?; } "ashow" => { let s = self.pop_string(); let ay = self.popn(); let ax = self.popn(); - self.show_with(&s, |_, _| (ax, ay)); + self.show_with(&s, |_, _| (ax, ay))?; } "widthshow" => { let s = self.pop_string(); let ch = self.popn() as u32; let cy = self.popn(); let cx = self.popn(); - self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) }); + self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) })?; } "awidthshow" => { let s = self.pop_string(); @@ -1735,18 +1804,18 @@ impl Interp { let ch = self.popn() as u32; let cy = self.popn(); let cx = self.popn(); - self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) }); + self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) })?; } "kshow" | "cshow" => { // The procedure between characters is not run. let s = self.pop_string(); self.pop(); - self.show_with(&s, |_, _| (0.0, 0.0)); + self.show_with(&s, |_, _| (0.0, 0.0))?; } "xshow" | "yshow" | "xyshow" => { let disp: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); let s = self.pop_string(); - self.explicit_show(&s, &disp, n); + self.explicit_show(&s, &disp, n)?; } "glyphshow" => { let name = self.pop().key(); @@ -1757,7 +1826,7 @@ impl Interp { let tmp = transformed(&d, Affine::IDENTITY); tmp.borrow_mut().insert("Encoding".into(), array(e)); self.gs.font = Some(tmp); - self.show_with(b"A", |_, _| (0.0, 0.0)); + self.show_with(b"A", |_, _| (0.0, 0.0))?; self.gs.font = Some(d); } "charpath" => { @@ -1765,14 +1834,17 @@ impl Interp { let s = self.pop_string(); let saved = std::mem::take(&mut self.gs.path); let start = self.gs.start; - match self.text_path(&s, |_, _| (0.0, 0.0)) { + match self.text_path(&s, |_, _| (0.0, 0.0))? { Some((glyphs, _)) => { - let mut p = saved; - p.extend(glyphs); + // The outlines join the path: each element counts against the budget and + // the path limit. + self.gs.path = saved; + self.room(glyphs.elements().len() + 1)?; + self.spend(glyphs.elements().len())?; + self.gs.path.extend(glyphs); if let Some(c) = self.gs.cur { - p.move_to(c); + self.gs.path.move_to(c); } - self.gs.path = p; self.gs.start = start; } None => self.gs.path = saved, @@ -1822,7 +1894,7 @@ impl Interp { /// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the /// advances. - fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) { + fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) -> Result<(), Flow> { let step = if n == "xyshow" { 2 } else { 1 }; let d: Vec<(f64, f64)> = (0..s.len()) .map(|k| { @@ -1837,13 +1909,13 @@ impl Interp { .collect(); let Some(fd) = self.gs.font.clone() else { self.b.skip("text (no font)"); - return; + return Ok(()); }; - let Some((font, m)) = self.font_of(&fd) else { return }; + let Some((font, m)) = self.font_of(&fd) else { return Ok(()) }; self.show_with(s, move |k, c| { let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO; let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0)); (dx - adv.x, dy - adv.y) - }); + }) } } diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 532e32cb..de49ec40 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -359,6 +359,100 @@ fn arcs_with_huge_angles_end_at_their_second_angle() { assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); } +/// Parses `body` after a square through [`eps_soon`]: the square stays, and the program stops at +/// the operation limit. +fn runs_out_of_budget(body: &str) { + let doc = eps_soon(&format!("0 0 10 10 rectfill {body}")); + assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["PostScript (operation limit)"], "{body}"); +} + +#[test] +fn paths_made_in_a_loop_run_out_of_the_budget() { + // Each curve, rectangle element and glyph outline element an operator makes counts against the + // operation budget: `arc` at a radius of 2.2 × 10^18 makes about 6,800 curves a call, + // `rectclip` with an array of 2,000 rectangles makes 10,000 elements, and `charpath` makes the + // outlines of 1,000 H's. + for body in [ + "{ newpath 0 0 2200000000000000000 0 1440 arc } loop", + "/a 8000 array def 0 1 7999 { a exch 0 put } for { gsave a rectclip grestore } loop", + "/Helvetica findfont 10 scalefont setfont /s 1000 string def 0 1 999 { s exch 72 put } for \ + { newpath 0 0 moveto s false charpath } loop", + ] { + runs_out_of_budget(body); + } +} + +#[test] +fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() { + // `pathbbox` reads each element of the current path, and `gsave`, `save` and `clip` copy it: + // each element counts against the operation budget. The path has 100,001 elements. + let path = "newpath 0 0 moveto 0 1 99999 { pop 1 0 rlineto } for"; + for body in ["{ pathbbox pop pop pop pop } loop", "{ gsave grestore } loop", "{ save restore } loop", "{ gsave clip grestore } loop"] { + runs_out_of_budget(&format!("{path} {body}")); + } +} + +#[test] +fn nested_loops_run_out_of_the_budget() { + // Each pass of `loop`, `repeat`, `for` and `forall` counts against the operation budget, and + // so does each item of a procedure that is not a name. `clear` empties the operand stack that + // the passes fill. + let literals = format!("{{ {} clear }} loop", "0 ".repeat(100_000)); + for body in [ + "{ {} loop } loop", + "{ 1000000 {} repeat } loop", + "{ 0 1 999999 {} for clear } loop", + "/s 1000000 string def { s {} forall clear } loop", + "/a 1000000 array def { a {} forall clear } loop", + "/d 1 dict def 0 1 99999 { d exch 0 put } for { d {} forall clear } loop", + literals.as_str(), + ] { + runs_out_of_budget(body); + } +} + +#[test] +fn paths_stop_at_the_path_limit() { + // A path holds at most 1,048,576 elements. An operator that would add more stops the program: + // the square drawn before it stays, and the square after it is left out. + for body in [ + "newpath 0 0 moveto 1100 { 1000 { 1 0 rlineto } repeat } repeat", + "newpath 0 0 moveto 0 0 0 0 0 0 1100 { 1000 { 6 copy rcurveto } repeat } repeat", + "newpath 0 0 moveto 1100 { 1000 { closepath } repeat } repeat", + // 1,024 turns at a radius of 2 × 10^17: about 1,171,000 curves. + "newpath 0 0 200000000000000000 0 368640 arc", + // At a radius of 10^300, kurbo divides the arc into more curves than a usize can count. + "newpath 0 0 10 300 exp 0 90 arc", + // The outlines of 100,000 H's, 13 elements each. + "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ + 0 0 moveto s false charpath", + // 210,000 rectangles of five elements each. + "/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill", + ] { + let doc = eps_soon(&format!("0 0 10 10 rectfill {body} 20 20 10 10 rectfill")); + assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["PostScript (path limit)"], "{body}"); + } + // A `moveto` replaces a `moveto` just before it (PostScript Language Reference §8.2, `moveto`), + // and so does the current point that `show` sets, so 550,000 pieces of text placed with + // `moveto` and `show` leave a path of one element. + let doc = eps_soon("/Helvetica findfont 10 scalefont setfont 550 { 1000 { 0 0 moveto ( ) show } repeat } repeat 0 0 10 10 rectfill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn pdf_paths_stop_at_the_path_limit() { + // A content stream's current path holds at most 1,048,576 elements: `re` (five elements each) + // and `h` stop the stream at the limit, and the square painted before it stays. + for (op, n) in [("0 0 1 1 re ", 210_000), ("h ", 1_100_000)] { + let doc = parse(&page_pdf(&format!("0 0 10 10 re f 0 0 m {} f 20 20 10 10 re f", op.repeat(n)), "", vec![])).unwrap(); + assert_eq!(doc.flatten().len(), 1, "{op}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["content (path limit)"], "{op}"); + } +} + #[test] fn closing_an_empty_path_is_a_no_op() { // "s", "b" and "b*" closed the path without a current point (kurbo asserts on that). From 75b75f8463f3f55e763012bc87846d6de2804616 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 01:25:46 +0530 Subject: [PATCH 076/185] fix(properties): reveal transform rows on double-click (#1456) --- contributors/people.toml | 3 + crates/model/src/stl.rs | 6 +- crates/ui-egui/src/panels/properties.rs | 11 ++++ crates/ui-egui/src/panels/timeline.rs | 4 ++ crates/ui-egui/src/state.rs | 6 +- crates/ui-egui/tests/ui_panels.rs | 86 +++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 705feb25..dc25b08e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -78,3 +78,6 @@ sync_github_name = true [people.moecr7] real_name = "Moein Saharkhiz" display_name = "onlymoein" + +[people.arnav-hiwarkar] +sync_github_name = true diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858a..34e9a7f0 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38e..84d8d408 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -244,6 +244,17 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(130.0); + let label = Rect::from_min_max(pos2(r.min.x + 42.0, r.min.y), pos2(vx - 20.0, r.max.y)); + let resp = ui.interact(label, egui::Id::new(("props-label", uid)), Sense::click()); + app.auto.add(&format!("{base}.label"), label, &pr.name); + if resp.double_clicked() { + app.ui.timeline.search.clear(); + app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Property(uid)); + if let Err(e) = crate::menus::frontend(app, ui.ctx(), "timeline.revealProps", json!({"props": [{"layer": lid, "prop": uid}]})) { + app.ui.status = e; + } + app.show_panel(PanelKind::Timeline); + } let merge = format!("props-{uid}"); // With several layers selected the edit applies to all: a scrub by the same change, a typed // value as it is. diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..2de97a4b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1895,6 +1895,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Layer) && Some(r.layer) == top) { app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum(); } + } else if let Some(crate::state::ScrollTo::Property(uid)) = scroll_to + && let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Prop { uid: p } if p == uid)) + { + app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum(); } if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b8ecb8c0..bbfe0c05 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -335,13 +335,15 @@ impl TimelineState { } } -/// Where a Timeline shortcut scrolls to. +/// Where the Timeline scrolls to. #[derive(Clone, Copy, Debug, PartialEq)] pub enum ScrollTo { /// D: the current time, centred in the time graph. Time, /// X: the topmost selected layer, at the top of the layers. Layer, + /// A revealed property, at the top of the rows. + Property(u64), } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -419,7 +421,7 @@ pub struct TimelineState { /// The name column shows Source Name instead of Layer Name (click its header). #[serde(default)] pub source_name: bool, - /// A scroll asked for by a shortcut (D, X), done when the Timeline is next drawn. + /// A pending scroll, applied when the Timeline is next drawn. #[serde(skip)] pub scroll_to: Option, } diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index 15439a02..0c54445b 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -125,6 +125,15 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.run_steps(2); } +fn double_click_at(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false, true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); +} + fn hover(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.input_mut().events.push(Event::PointerMoved(p)); h.run_steps(3); @@ -506,6 +515,83 @@ fn properties_scale_link_shares_the_timeline_state() { assert!(!h.state().ui.timeline.unlinked.contains(&uid), "re-linking clears the shared state"); } +#[test] +fn properties_label_double_click_reveals_without_editing() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id; + let other = h.state_mut().session.execute("layer.newSolid", json!({"name": "Other", "color": "#204060"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("prop.addKey", json!({"layer": layer.0, "path": "transform/opacity", "time": 1.0, "value": 50.0})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [layer.0]})).unwrap(); + let comp = h.state().session.active_comp().unwrap(); + let opacity = comp.layer(layer).unwrap().props.prop("transform/opacity").unwrap().uid; + let other_scale = comp.layers.iter().find(|l| l.id.0 == other).unwrap().props.prop("transform/scale").unwrap().uid; + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::frontend(h.state_mut(), &ctx, "timeline.revealProps", json!({"props": [{"layer": other, "prop": other_scale}]})).unwrap(); + open(&mut h, "properties"); + open(&mut h, "renderQueue"); + h.state_mut().ui.timeline.search = "no matching property".into(); + h.run_steps(3); + let value = h.state().auto.find(&format!("properties.prop.{opacity}.value")).unwrap().rect; + let label = pos2(value[0] - 60.0, value[1] + value[3] / 2.0); + let row = format!("timeline.prop.{opacity}.row"); + click_at(&mut h, label); + assert_eq!(h.state().ui.timeline.search, "no matching property"); + assert!(h.state().auto.find(&row).is_none()); + h.run_steps(30); + let before = serde_json::to_value(h.state().session.project.as_ref()).unwrap(); + let undo = h.state().session.history.undo.len(); + let selected = h.state().session.state.selected_layers.clone(); + for _ in 0..2 { + double_click_at(&mut h, label); + assert!(h.state().auto.find(&row).is_some(), "Opacity is visible for keyframing"); + assert!(h.state().auto.find(&format!("timeline.prop.{other_scale}.row")).is_some(), "the other layer keeps its reveal"); + assert_eq!(h.state().ui.timeline.search, ""); + h.run_steps(30); + } + assert_eq!(serde_json::to_value(h.state().session.project.as_ref()).unwrap(), before, "revealing preserves values and keyframes"); + assert_eq!(h.state().session.history.undo.len(), undo); + assert_eq!(h.state().session.state.selected_layers, selected); +} + +#[test] +fn properties_reveal_scrolls_to_the_displayed_layer_in_a_multiple_selection() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id; + for _ in 0..40 { + h.state_mut().session.execute("layer.newSolid", json!({"name": "Spacer", "color": "#204060"})).unwrap(); + } + let top = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers": [layer.0, top.0]})).unwrap(); + let uid = h.state().session.active_comp().unwrap().layer(layer).unwrap().props.prop("transform/opacity").unwrap().uid; + open(&mut h, "properties"); + h.state_mut().toggle_maximize(PanelKind::Properties); + h.run_steps(3); + let r = h.state().auto.find(&format!("properties.prop.{uid}.label")).unwrap().rect; + double_click_at(&mut h, pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)); + assert!(h.state().auto.find(&format!("timeline.prop.{uid}.row")).is_some(), "the displayed layer's property is in view"); + assert_eq!(h.state().session.state.selected_layers, [layer, top]); +} + +#[test] +fn properties_transform_labels_reveal_the_corresponding_property() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers": [layer.0]})).unwrap(); + let props: Vec = + h.state().session.active_comp().unwrap().layer(layer).unwrap().transform().unwrap().props().filter(|p| !p.three_d_only).map(|p| p.uid).collect(); + open(&mut h, "properties"); + for uid in &props { + h.state_mut().ui.timeline.open_layers.remove(&layer.0); + h.run_steps(30); + assert!(h.state().auto.find(&format!("timeline.prop.{uid}.row")).is_none()); + let r = h.state().auto.find(&format!("properties.prop.{uid}.label")).unwrap().rect; + double_click_at(&mut h, pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)); + for candidate in &props { + assert_eq!(h.state().auto.find(&format!("timeline.prop.{candidate}.row")).is_some(), candidate == uid); + } + } +} + /// Gap 7: the Backgrounds category is listed under * Animation Presets and a preset applies on double-click. #[test] fn background_presets_are_listed_and_apply() { From 769a019e7da5976124fc8b57de1ff5011680e66d Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:55:46 +0300 Subject: [PATCH 077/185] pdf: bound CFF endchar seac recursion so a cyclic font can't overflow the stack --- crates/pdf/src/cff.rs | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 1c8c603e..79e68387 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -533,6 +533,10 @@ impl Cff { /// A glyph's outline in glyph space (`font_matrix` maps it to text space) and its advance. pub fn outline(&self, gid: u16) -> Option<(BezPath, f64)> { + self.outline_depth(gid, 0) + } + + fn outline_depth(&self, gid: u16, depth: usize) -> Option<(BezPath, f64)> { let r = self.charstrings.get(gid as usize)?.clone(); let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize; let fd = fd.min(self.lsubrs.len() - 1); @@ -547,13 +551,17 @@ impl Cff { _ => self.widths[fd].0, }; let mut path = st.path; - // endchar seac: base and accent glyphs by standard code. - if let Some((adx, ady, b, a)) = st.seac { + // endchar seac: base and accent glyphs by standard code. The nesting is bounded like the + // Type 1 interpreter's: a font whose accented glyphs seac into each other (or into + // themselves) must not recurse until the stack overflows. + if let Some((adx, ady, b, a)) = st.seac + && depth < 2 + { let find = |code: u8| crate::encoding::Base::Standard.name(code).and_then(|n| self.gid_by_name(&n)); - if let Some((bp, _)) = find(b).and_then(|g| self.outline(g)) { + if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1)) { path.extend(bp); } - if let Some((ap, _)) = find(a).and_then(|g| self.outline(g)) { + if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1)) { path.extend(kurbo::Affine::translate((adx, ady)) * ap); } } @@ -1048,4 +1056,30 @@ mod tests { assert!((p.area().abs() - 250_000.0).abs() < 1e-6, "{}", p.area()); assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); } + + /// An `endchar` seac whose base and accent name its own glyph used to recurse until the + /// stack overflowed and aborted the app; the depth limit cuts it off like the Type 1 + /// interpreter's. + #[test] + fn seac_cycles_are_cut_off_not_a_stack_overflow() { + let enc = |v: i32| -> Vec { + if (-107..=107).contains(&v) { + vec![(v + 139) as u8] + } else { + let mut o = vec![28]; + o.extend_from_slice(&(v as i16).to_be_bytes()); + o + } + }; + // `w adx ady bchar achar endchar`: both components are 'A' (standard code 65), this glyph. + let mut cs = vec![]; + for v in [500, 0, 0, 65, 65] { + cs.extend(enc(v)); + } + cs.push(14); + let font = write_test_cff(&[(".notdef", vec![14]), ("A", cs)]); + let cff = Cff::parse(&font).unwrap(); + let (p, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap(); + assert!(p.elements().is_empty()); + } } From 790e2387d653668ca2b92bfefeee4a7f15eaa566 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:56:27 +0300 Subject: [PATCH 078/185] pdf: endchar takes its width by operand parity like the stems and rmoveto --- crates/pdf/src/cff.rs | 46 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 1c8c603e..59b49ec0 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -835,7 +835,9 @@ impl T2<'_> { } 11 => return, 14 => { - self.take_width(false); + // A width precedes the operands when the count is odd (5 with seac, 1 alone); + // the no-width counts (0 and 4) are even, like the stems' and rmoveto's. + self.take_width(true); if self.stack.len() >= 4 { let n = self.stack.len(); let s = &self.stack[n - 4..]; @@ -1048,4 +1050,46 @@ mod tests { assert!((p.area().abs() - 250_000.0).abs() < 1e-6, "{}", p.area()); assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); } + + /// A four-operand `endchar` (`adx ady bchar achar`, no width) is a seac like the Type 1 + /// interpreter's: `endchar` must not eat `adx` as a width, and a five-operand one reads its + /// width first. + #[test] + fn endchar_reads_its_width_by_operand_count() { + let enc = |v: i32| -> Vec { + if (-107..=107).contains(&v) { + vec![(v + 139) as u8] + } else { + let mut o = vec![28]; + o.extend_from_slice(&(v as i16).to_be_bytes()); + o + } + }; + // The base glyph: a square drawn without a width (`500 500 -500 hlineto endchar`). + let mut square = vec![]; + for v in [500, 500, -500] { + square.extend(enc(v)); + } + square.extend([6, 14]); + // Four operands: `adx ady bchar achar endchar`, no width to take. + let mut seac = vec![]; + for v in [10, 20, 65, 65] { + seac.extend(enc(v)); + } + seac.push(14); + let font = write_test_cff(&[(".notdef", vec![14]), ("A", square.clone()), ("Aacute", seac)]); + let cff = Cff::parse(&font).unwrap(); + let (p, _) = cff.outline(cff.gid_by_name("Aacute").unwrap()).unwrap(); + assert!(!p.elements().is_empty(), "the base glyph of the seac is part of the outline"); + // Five operands: the first is the width, the last four the seac. + let mut wide = vec![]; + for v in [600, 0, 0, 65, 65] { + wide.extend(enc(v)); + } + wide.push(14); + let font = write_test_cff(&[(".notdef", vec![14]), ("A", square), ("Awide", wide)]); + let cff = Cff::parse(&font).unwrap(); + let (_, w) = cff.outline(cff.gid_by_name("Awide").unwrap()).unwrap(); + assert_eq!(w, 600.0); + } } From c6736d8fc0222692691fa20866575d5b5993cc8d Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:57:37 +0300 Subject: [PATCH 079/185] pdf: checked_add for CFF Private DICT and Type 1 RD offsets from the file --- crates/pdf/src/cff.rs | 67 +++++++++++++++++++++++++++++++++++++++-- crates/pdf/src/type1.rs | 15 ++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 1c8c603e..eb68a6a7 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -394,11 +394,16 @@ impl Cff { let private = |pd: &[(u16, Vec)]| -> (Vec>, (f64, f64)) { let Some(pv) = get(pd, 18).filter(|v| v.len() == 2) else { return (vec![], (0.0, 0.0)) }; let (size, off) = (pv[0] as usize, pv[1] as usize); - let Some(bytes) = d.get(off..off + size) else { return (vec![], (0.0, 0.0)) }; + let Some(bytes) = off.checked_add(size).and_then(|end| d.get(off..end)) else { return (vec![], (0.0, 0.0)) }; let pr = dict(bytes); let nominal = get(&pr, 21).and_then(|v| v.first().copied()).unwrap_or(0.0); let default = get(&pr, 20).and_then(|v| v.first().copied()).unwrap_or(0.0); - let subrs = get(&pr, 19).and_then(|v| v.first().copied()).and_then(|s| index(d, off + s as usize)).map(|x| x.0).unwrap_or_default(); + let subrs = get(&pr, 19) + .and_then(|v| v.first().copied()) + .and_then(|s| off.checked_add(s as usize)) + .and_then(|p| index(d, p)) + .map(|x| x.0) + .unwrap_or_default(); (subrs, (default, nominal)) }; let mut lsubrs = vec![]; @@ -1048,4 +1053,62 @@ mod tests { assert!((p.area().abs() - 250_000.0).abs() < 1e-6, "{}", p.area()); assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); } + + /// A CFF whose top DICT points its Private DICT at offsets near `usize::MAX`: `size` and `off` + /// come from the file, and reading the Private DICT must not overflow `off + size`. + #[test] + fn huge_private_dict_offsets_do_not_overflow() { + fn idx(items: &[Vec]) -> Vec { + let mut out = (items.len() as u16).to_be_bytes().to_vec(); + if items.is_empty() { + return out; + } + out.push(4); + let mut off = 1u32; + out.extend_from_slice(&off.to_be_bytes()); + for it in items { + off += it.len() as u32; + out.extend_from_slice(&off.to_be_bytes()); + } + for it in items { + out.extend_from_slice(it); + } + out + } + let int = |v: i32| { + let mut o = vec![29]; + o.extend_from_slice(&v.to_be_bytes()); + o + }; + // The BCD real "1E300": a size and an offset that both saturate at `usize::MAX`. + let huge = vec![0x1E, 0x1B, 0x30, 0x0F]; + let header = [1u8, 0, 4, 4]; + let names = idx(&[b"Test".to_vec()]); + let strings = idx(&[]); + let gsubrs = idx(&[]); + let charset = vec![0u8]; + let cs = idx(&[vec![14]]); // one `.notdef` charstring: endchar + let top_len = 5 + 1 + 5 + 1 + huge.len() * 2 + 1; + let top_index_len = 2 + 1 + 8 + top_len; + let base = header.len() + names.len() + top_index_len + strings.len() + gsubrs.len(); + let cs_off = base + charset.len(); + let mut top = vec![]; + top.extend(int(0)); + top.push(15); // charset: ISOAdobe + top.extend(int(cs_off as i32)); + top.push(17); // CharStrings + top.extend(&huge); + top.extend(&huge); + top.push(18); // Private: [size, offset] + assert_eq!(top.len(), top_len); + let mut out = header.to_vec(); + out.extend(names); + out.extend(idx(&[top])); + out.extend(strings); + out.extend(gsubrs); + out.extend(charset); + out.extend(cs); + let cff = Cff::parse(&out).expect("the damaged Private DICT is skipped, the font parses"); + assert_eq!(cff.glyph_count(), 1); + } } diff --git a/crates/pdf/src/type1.rs b/crates/pdf/src/type1.rs index f489ad2a..4b3e0575 100644 --- a/crates/pdf/src/type1.rs +++ b/crates/pdf/src/type1.rs @@ -143,7 +143,7 @@ impl Type1 { q += 1; } q += 1; - let bytes = priv_.get(q..q + n)?; + let bytes = priv_.get(q..q.checked_add(n)?)?; Some((cs(bytes), q + n)) }; if let Some(p) = find(&priv_, b"/Subrs", 0) { @@ -532,4 +532,17 @@ mod tests { assert!((p.area().abs() - 250_000.0).abs() < 1e-6); assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); } + + /// A `/Subrs` entry whose `RD` length is `usize::MAX`: the bytes are not in the file, and + /// `q + n` must not overflow. + #[test] + fn huge_rd_lengths_do_not_overflow() { + let mut out = b"%!PS-AdobeFont-1.0: Test 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\ncurrentfile eexec\n".to_vec(); + let mut priv_ = b"dup /Private 8 dict dup begin /lenIV -1 def\n/Subrs 1 array\ndup 0 18446744073709551615 RD\n".to_vec(); + priv_.extend_from_slice(b"end\n/CharStrings 1 dict dup begin\n/.notdef 4 RD \x8b\x8b\r\x0e ND\nend\nend\nmark currentfile closefile\n"); + out.extend(encrypt(&priv_, EEXEC_R, 4)); + out.extend_from_slice(b"\n0000000000000000\ncleartomark\n"); + let t = Type1::parse(&out).expect("the damaged Subrs entry is skipped"); + assert!(t.has_glyph(".notdef")); + } } From 640bf30503c187b80afbf029eb62f7338b18fc57 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:11:47 -0700 Subject: [PATCH 080/185] Packaging: repair the AppStream screenshot reference Assisted-by: OpenAI Codex (GPT-6) Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- packaging/linux/ai.storyteller.effectcraft.metainfo.xml.in | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/linux/ai.storyteller.effectcraft.metainfo.xml.in b/packaging/linux/ai.storyteller.effectcraft.metainfo.xml.in index d8377c4c..50408021 100644 --- a/packaging/linux/ai.storyteller.effectcraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.effectcraft.metainfo.xml.in @@ -37,8 +37,8 @@ - - https://raw.githubusercontent.com/storytold/effectcraft/main/docs/images/effectcraft-demo.jpg + + https://raw.githubusercontent.com/storytold/effectcraft/05955c08b4fd025f1a09882df72e6dbff1a8148a/docs/images/effectcraft-hero.png From 642eed0802d6516a691c4e589a6983d91adbc665 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 14 Oct 2026 14:39:26 -0600 Subject: [PATCH 081/185] Properties/Timeline/Effect Controls: a typed component edits only that component of the other selected layers (#1434) With several layers selected, typing a value into one dimension (say Z Position) sent the edited layer's whole value with selected: true, and prop.set gave every selected layer that same value: they all took the first layer's X and Y and stacked on top of it. Scrubbing was fine (it sends an offset). prop.set takes dims: with selected and a typed value, the other selected layers get only those components and keep the rest of their own value (Position with Separate Dimensions included). The three panels send the components the edit changed (both for linked Scale). Without dims, agents still set the whole value on every selected layer. Fixes #1434 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/engine/src/commands/prop.rs | 24 ++++++++++- crates/engine/src/tests_timeline.rs | 42 ++++++++++++++++++ crates/ui-egui/src/panels/effect_controls.rs | 5 +++ crates/ui-egui/src/panels/properties.rs | 5 +++ crates/ui-egui/src/panels/timeline.rs | 5 +++ crates/ui-egui/tests/ui_inline_edit.rs | 45 ++++++++++++++++++++ 6 files changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 29da9c1e..8f263818 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -241,7 +241,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 { } /// `prop.set`; with `selected`, also on the other selected layers: the same value, or with -/// `offset` (a scrub) the same change from each layer's own value. With `offset` and +/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims` +/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers, +/// which keep the rest of their own value (#1434). With `offset` and /// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change /// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`, /// one that a continuing scrub keeps folding into). @@ -261,6 +263,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { .then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect()); let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len()))); let scalar = p.get("value").is_some_and(Value::is_number); + // Typed into some components only: the others stay each layer's own. + let dims: Option> = match (&delta, p.get("dims").and_then(Value::as_array), &new) { + (None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()), + _ => None, + }; + let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| { + if let (Some(dims), Some(n)) = (&dims, &new) + && let Some(mut c) = current_components(s, cid, olid, ouid) + { + for &d in dims { + if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) { + *slot = *v; + } + } + q["value"] = json!(c); + } + }; // `p` aimed at (layer, prop) with its value moved by `k` × the change. let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| { if let Some(d) = &delta @@ -291,6 +310,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { } let mut q = twin_params(p, olid, ouid); shifted(s, &mut q, olid, ouid, k); + typed(s, &mut q, olid, ouid); // (a layer that can't take it, e.g. a clamped or static property, keeps its value) let _ = set(s, &q); } @@ -1229,7 +1249,7 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}", has_layers, set_selected ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 71a9943c..a7aa8bac 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -1193,3 +1193,45 @@ fn relative_trim_moves_every_selected_layer_by_the_same_delta() { s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap(); assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a)); } + +/// #1434: with several 3D layers selected, typing a value into one component (Z Position) sets +/// that component on each of them; their other components stay their own. Before, every +/// selected layer took the edited layer's whole value and they piled up on top of it. +#[test] +fn a_typed_component_applies_only_that_component_to_the_selected_layers() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + for (l, xy) in [(a, [10.0, 20.0]), (b, [110.0, 120.0]), (c, [210.0, 220.0])] { + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 5.0]})).unwrap(); + } + s.execute("layer.select", json!({"layers": [a, b, c]})).unwrap(); + let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value.components(); + let uid = prop(&s, a, "transform/position").uid; + // The panels send the edited layer's value with the typed component, and which one it was. + let undo = s.history.undo.len(); + s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, -300.0], "selected": true, "offset": false, "dims": [2]})).unwrap(); + assert_eq!(pos(&s, a), vec![10.0, 20.0, -300.0]); + assert_eq!(pos(&s, b), vec![110.0, 120.0, -300.0], "B keeps its X and Y"); + assert_eq!(pos(&s, c), vec![210.0, 220.0, -300.0], "C keeps its X and Y"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pos(&s, b), vec![110.0, 120.0, 5.0]); + + // Linked Scale: both edited components go to every layer, Z stays each layer's own. + s.execute("prop.set", json!({"layer": c, "path": "transform/scale", "value": [50.0, 50.0, 30.0]})).unwrap(); + let scale_uid = prop(&s, a, "transform/scale").uid; + s.execute("prop.set", json!({"layer": a, "prop": scale_uid, "value": [200.0, 200.0, 100.0], "selected": true, "dims": [0, 1]})).unwrap(); + assert_eq!(prop(&s, c, "transform/scale").value.components(), vec![200.0, 200.0, 30.0]); + + // Position with Separate Dimensions on one of the other layers. + s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, 40.0], "selected": true, "dims": [2]})).unwrap(); + let axis = |m: &str| prop(&s, b, &format!("transform/{m}")).value.as_f64(); + assert_eq!((axis("positionX"), axis("positionY"), axis("positionZ")), (110.0, 120.0, 40.0)); + + // Without `dims` (agents setting a whole value) every selected layer takes the value as is. + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap(); + assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]); +} diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39..ecfed5a2 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -377,6 +377,11 @@ fn prop_row( let mut nc = c.clone(); nc[d] = nv; set(actions, json!(nc)); + // Typed into this component only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!([d]); + } } x = vr.max.x + 8.0; } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38e..5cb36513 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -306,6 +306,11 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } } set(actions, json!(nc)); + // Typed into these components only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!((from..from + shown).collect::>()); + } } x = vr.max.x + 10.0; } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..f54b1cd4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4064,6 +4064,11 @@ fn value_editor( } } set(actions, json!(nc)); + // Typed into these components only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!((from..from + shown).collect::>()); + } } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; if d + 1 < n { diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b..d7e532d7 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -539,3 +539,48 @@ fn arrow_keys_move_the_caret_in_rename_fields() { let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); assert_eq!(layer_name, "Pla-te"); } + +/// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X +/// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so +/// they stacked on top of it and seemed to disappear. +#[test] +fn typing_one_component_with_several_layers_selected_keeps_their_other_components() { + let (mut h, _) = harness(); + let (a, b, transform) = { + let s = &mut h.state_mut().session; + let mut ids = vec![]; + for (name, xy) in [("A", [40.0, 50.0]), ("B", [200.0, 120.0])] { + let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 0.0]})).unwrap(); + ids.push(l); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(ids[0])).unwrap().props.sub("transform").unwrap().uid; + (ids[0], ids[1], tr) + }; + h.state_mut().ui.timeline.open_layers.insert(a); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| { + h.state() + .session + .active_comp() + .unwrap() + .layer(effectcraft_engine::project::LayerId(l)) + .unwrap() + .props + .prop("transform/position") + .unwrap() + .value + .components() + }; + let position = + h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(a)).unwrap().props.prop("transform/position").unwrap().uid; + let p = center(&h, &format!("timeline.prop.{position}.value.2")); + click(&mut h, p, 1); + type_text(&mut h, "-300"); + key(&mut h, Key::Enter); + assert_eq!(pos(&h, a), [40.0, 50.0, -300.0]); + assert_eq!(pos(&h, b), [200.0, 120.0, -300.0], "B keeps its own X and Y"); +} From 0f38f507ee9dff7f8e2737dfeb13e577cf5d451c Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:10:49 +0300 Subject: [PATCH 082/185] ui-egui: a click that opens the colour picker no longer closes it in the same frame (#1476) --- crates/ui-egui/src/color_picker.rs | 14 ++++++++++++-- crates/ui-egui/tests/ui_color_hex.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs index 35d8e4eb..efaae6d7 100644 --- a/crates/ui-egui/src/color_picker.rs +++ b/crates/ui-egui/src/color_picker.rs @@ -45,7 +45,13 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { out.close = Some(false); } - if pressed_outside(ui.ctx(), &area.response) { + // A press in the pass that first draws the picker must not close it: an Area's rect is only + // at its requested position from its second pass on (the first pass reports it at the + // origin), so the click that just opened the picker would be read as a press outside it and + // the picker would flash and close in that one frame (#1476). + let pass_id = id.with("open-pass"); + let drawn_before = ui.data(|d| d.get_temp::(pass_id)).is_some_and(|p| p < ui.ctx().cumulative_pass_nr()); + if drawn_before && pressed_outside(ui.ctx(), &area.response) { out.close.get_or_insert(true); } if let Some(keep) = out.close { @@ -56,13 +62,17 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) ui.data_mut(|d| { d.insert_temp(id.with("open"), false); d.remove::<[f32; 3]>(orig_id); + d.remove::(pass_id); // Closed: the next Escape is the dialog's again. d.remove::(open_pass_id()); }); } else { // Still open after this pass: dialogs leave Escape to the picker (see `open_recently`). let pass = ui.ctx().cumulative_pass_nr(); - ui.data_mut(|d| d.insert_temp(open_pass_id(), pass)); + ui.data_mut(|d| { + d.insert_temp(pass_id, pass); + d.insert_temp(open_pass_id(), pass); + }); } out.changed } diff --git a/crates/ui-egui/tests/ui_color_hex.rs b/crates/ui-egui/tests/ui_color_hex.rs index 90eb19b8..4f8a8ab1 100644 --- a/crates/ui-egui/tests/ui_color_hex.rs +++ b/crates/ui-egui/tests/ui_color_hex.rs @@ -114,6 +114,32 @@ fn is_open(h: &Harness<'_, [f32; 3]>) -> bool { h.query_by_role_and_label(Role::TextInput, "#").is_some() } +/// #1476: the click that opens the picker must not close it. The picker's area only reports its +/// real position from its second pass — on the pass it first draws, its rect is at the origin — +/// so a click on a swatch outside that stale rect was read as a press outside the popup and the +/// picker flashed and closed in the frame it opened. +#[test] +fn a_click_that_opens_the_picker_does_not_close_it_in_the_same_frame() { + let mut h = Harness::builder().with_size(vec2(560.0, 400.0)).build_ui_state( + |ui, c: &mut [f32; 3]| { + // Away from the top-left corner: the stale first-pass rect covers it. + ui.allocate_space(vec2(440.0, 120.0)); + ui.horizontal(|ui| { + ui.add_space(440.0); + widgets::srgb_color_button(ui, c); + }); + }, + [1.0, 0.0, 0.0], + ); + h.run_steps(2); + let button = h.get_by_role(Role::ColorWell).rect().center(); + h.input_mut().events.push(Event::PointerMoved(button)); + h.input_mut().events.push(Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(is_open(&h), "the click that opens the picker must not close it"); +} + #[test] fn the_dialog_colour_button_stores_srgb() { let mut h = picker([1.0, 1.0, 1.0]); From e0f25c77bb56d763d3b5f1312f76d0120f06769b Mon Sep 17 00:00:00 2001 From: caldiag Date: Wed, 14 Oct 2026 18:27:25 -0300 Subject: [PATCH 083/185] when double-clicking an effect property, it reveals that effect in the timeline --- crates/ui-egui/src/panels/effect_controls.rs | 5 ++- crates/ui-egui/src/panels/timeline.rs | 41 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39..73798880 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -272,7 +272,10 @@ fn prop_row( let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y)); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text); - ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); + let name_response = ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); + if name_response.double_clicked() && super::timeline::reveal_effect_property(app, layer.id.0, uid) { + app.raise_panel(crate::dock::PanelKind::Timeline); + } app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); let merge = format!("ec-{uid}"); // With several layers selected the edit applies to the same effect property of each (#491). diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..c7114a28 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1134,6 +1134,21 @@ pub(crate) fn open_effect_paths(app: &mut EffectcraftApp, layer: u64, props: &[u app.ui.timeline.open_groups.extend(open); } +/// Show an Effect Controls property in its layer's Timeline Effects view. +pub(crate) fn reveal_effect_property(app: &mut EffectcraftApp, layer: u64, prop: u64) -> bool { + let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) else { return false }; + let Some(fx) = l.effects().map(|g| g.uid) else { return false }; + let path = enclosing_groups(l, prop); + if path.first() != Some(&fx) { + return false; + } + let tl = &mut app.ui.timeline; + tl.open_layers.insert(layer); + tl.layer_reveal.insert(layer, vec!["effects".into()]); + tl.open_groups.extend(path); + true +} + /// After a reveal shortcut that picks properties by state (EE expressions): those inside effects /// on `layers` show under their effect, twirled open. pub(crate) fn open_revealed(app: &mut EffectcraftApp, layers: &[u64], kinds: &[String]) { @@ -4317,6 +4332,32 @@ mod tests { assert!(labels(&app).iter().any(|r| r.trim() == "Scale")); } + #[test] + fn reveal_effect_property_opens_layer_effect_path_in_timeline() { + let mut app = app(); + let comp = app.session.active_comp().unwrap().clone(); + let bx = comp.layers.iter().find(|l| l.name == "Box").unwrap().id.0; + let prop = comp + .layer(LayerId(bx)) + .and_then(|layer| layer.effects()) + .and_then(|effects| effects.groups().next()) + .and_then(|effect| effect.get("blurriness")) + .unwrap() + .uid; + + assert!(reveal_effect_property(&mut app, bx, prop)); + assert!(app.ui.timeline.open_layers.contains(&bx)); + assert_eq!(app.ui.timeline.layer_reveal.get(&bx), Some(&vec!["effects".to_string()])); + + let rows = labels(&app); + let effect = rows.iter().position(|row| row.trim() == ">Gaussian Blur").unwrap(); + assert_eq!(rows.get(effect + 1).map(String::as_str).map(str::trim), Some("Blurriness"), "{rows:?}"); + assert!(rows.iter().any(|row| row.trim() == "Blur Dimensions"), "Effects view shows the effect's other properties too: {rows:?}"); + + let transform_prop = comp.layer(LayerId(bx)).and_then(|layer| layer.transform()).and_then(|group| group.get("position")).unwrap().uid; + assert!(!reveal_effect_property(&mut app, bx, transform_prop), "non-effect properties do not change the Effects view"); + } + #[test] fn columns_toggle_and_layout() { let mut app = app(); From bb2b7a6f870af76bdf71d50760412abb5e3a8680 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:02:49 +0300 Subject: [PATCH 084/185] engine: atomic writes use an exclusive per-call temporary, so parallel writers cannot mix or follow a planted symlink (#1233) --- crates/engine/src/config.rs | 38 ++++++++++++++------ crates/engine/src/tests_settings.rs | 54 +++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index c333c973..68f403dd 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -164,10 +164,15 @@ impl ConfigStore for DirConfig { } } -/// The temporary file an atomic write of `path` goes through. +/// The temporary file an atomic write of `path` goes through. Unique per call (process id plus a +/// counter), so two writers saving the same destination never share a temporary — one cannot +/// truncate, rename or clean up another's — and there is no predictable name for a pre-existing +/// file or symlink to sit at. pub fn temp_path(path: &Path) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); - name.push(".tmp"); + name.push(format!(".{}-{}.tmp", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); path.with_file_name(name) } @@ -177,19 +182,30 @@ pub fn temp_path(path: &Path) -> PathBuf { pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { atomic_write_with(path, data, |tmp, data| { use std::io::Write; - let mut f = std::fs::File::create(tmp)?; + // Exclusive creation: an entry already at the temporary's name (a leftover from a crashed + // writer, or something planted there) is never opened, truncated or written through. + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?; f.write_all(data)?; f.sync_all() }) } -/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A -/// failed write removes the temporary file and leaves `path` untouched. -pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> { - let tmp = temp_path(path); - if let Err(e) = write(&tmp, data) { - let _ = std::fs::remove_file(&tmp); - return Err(e); +/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). The +/// temporary belongs to this call alone: the writer must create it exclusively, and a failed +/// write removes only its own temporary and leaves `path` untouched. An `AlreadyExists` from the +/// writer means the name was taken (never opened by us): that entry is left alone and another +/// unique name is tried. +pub fn atomic_write_with(path: &Path, data: &[u8], mut write: impl FnMut(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> { + for _ in 0..16 { + let tmp = temp_path(path); + match write(&tmp, data) { + Ok(()) => return std::fs::rename(&tmp, path), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + } } - std::fs::rename(&tmp, path) + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{}: no free temporary name", path.display()))) } diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 458c886f..4ce956ca 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::json; -use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path}; +use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with}; use crate::prefs::{PREFS_FILE, Prefs}; use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize}; use crate::{Session, autosave}; @@ -560,7 +560,9 @@ fn atomic_write_survives_a_crash_mid_write() { }); assert!(r.is_err()); assert_eq!(std::fs::read(&f).unwrap(), b"version 1"); - assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up"); + let leftovers: Vec = + std::fs::read_dir(&d).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).filter(|n| n != "p.ecproj").collect(); + assert!(leftovers.is_empty(), "the partial temporary is cleaned up: {leftovers:?}"); // A temp file left by a hard kill doesn't count as an auto-save. std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap(); assert!(autosave::existing(&d, "p").is_empty()); @@ -569,6 +571,54 @@ fn atomic_write_survives_a_crash_mid_write() { let _ = std::fs::remove_dir_all(d); } +#[cfg(unix)] +#[test] +fn atomic_write_does_not_follow_a_planted_temporary_symlink() { + let d = tmp("atomic-link"); + let f = d.join("p.ecproj"); + let sentinel = d.join("sentinel.txt"); + std::fs::write(&sentinel, b"precious").unwrap(); + // Someone plants a symlink at the temporary name the write used to go through. + std::os::unix::fs::symlink(&sentinel, d.join("p.ecproj.tmp")).unwrap(); + atomic_write(&f, b"new").unwrap(); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"precious", "a symlink at the temporary name is never followed"); + assert_eq!(std::fs::read(&f).unwrap(), b"new"); + let _ = std::fs::remove_dir_all(d); +} + +#[cfg(unix)] +#[test] +fn atomic_writes_in_parallel_never_publish_a_mixture() { + use std::sync::{Arc, Barrier}; + let d = tmp("atomic-race"); + let f = d.join("p.ecproj"); + let a = vec![b'A'; 64]; + let b = vec![b'B'; 64]; + // Writer A writes the first half of its temporary, then waits while writer B runs against + // the same destination, and only then finishes. + let (half, published) = (Arc::new(Barrier::new(2)), Arc::new(Barrier::new(2))); + let (f2, a2) = (f.clone(), a.clone()); + let (half2, published2) = (half.clone(), published.clone()); + let t = std::thread::spawn(move || { + atomic_write_with(&f2, &a2, |tmp, data| { + use std::io::Write; + let mut fh = std::fs::File::create(tmp)?; + fh.write_all(&data[..32])?; + half2.wait(); + published2.wait(); + fh.write_all(&data[32..])?; + fh.sync_all() + }) + }); + half.wait(); + atomic_write(&f, &b).unwrap(); + published.wait(); + let _ = t.join(); + let got = std::fs::read(&f).unwrap(); + assert!(got == a || got == b, "the destination holds one complete write, never a mixture: {}", String::from_utf8_lossy(&got)); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn autosave_rotates_through_max_versions() { let d = tmp("rotate"); From 2603a6f0e0c7d0b439086e80f6c9f3760ad7e50e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:30:54 +0200 Subject: [PATCH 085/185] Export: render in parallel once the GPU stops for good --- crates/export/src/pipeline.rs | 64 ++++++++++++++++++++++++++++++++--- crates/gpu/src/lib.rs | 4 +++ crates/render/src/lib.rs | 6 ++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index a0093f4c..8dcee03e 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -48,22 +48,43 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { /// one window of rendered images is held in memory. Chunks hold no lock or slot, so a failing /// or panicking frame cannot leak one, and nested parallelism (or a single thread) cannot /// deadlock. +#[cfg(test)] pub(crate) fn map_frames( ks: Vec, cap: Option, render: impl Fn(u64) -> I + Sync + Send, encode: impl Fn(u64, I) -> T + Sync + Send, +) -> Vec { + map_frames_while(ks, cap, || true, render, encode) +} + +/// [`map_frames`] whose `cap` holds only while `capped()`: once it turns false (the GPU stopped +/// for good, so every frame falls back to the CPU), the remaining frames render in parallel +/// like the uncapped CPU path instead of one chunk of `cap` at a time. +pub(crate) fn map_frames_while( + ks: Vec, + cap: Option, + capped: impl Fn() -> bool, + render: impl Fn(u64) -> I + Sync + Send, + encode: impl Fn(u64, I) -> T + Sync + Send, ) -> Vec { use rayon::prelude::*; - let Some(n) = cap.map(|n| n.max(1)) else { + let Some(n) = cap.map(|n| n.max(1)).filter(|_| capped()) else { return ks.into_par_iter().map(|k| encode(k, render(k))).collect(); }; let window = n.max(rayon::current_num_threads()); let mut out = Vec::with_capacity(ks.len()); - for win in ks.chunks(window) { + for (w, win) in ks.chunks(window).enumerate() { + if !capped() { + out.par_extend(ks[w * window..].par_iter().map(|&k| encode(k, render(k)))); + break; + } let mut imgs = Vec::with_capacity(win.len()); - for chunk in win.chunks(n) { - imgs.par_extend(chunk.par_iter().map(|&k| render(k))); + let mut i = 0; + while i < win.len() { + let end = if capped() { (i + n).min(win.len()) } else { win.len() }; + imgs.par_extend(win[i..end].par_iter().map(|&k| render(k))); + i = end; } out.par_extend(win.par_iter().copied().zip(imgs.into_par_iter()).map(|(k, img)| encode(k, img))); } @@ -187,7 +208,10 @@ impl<'a> Cx<'a> { } return out; } - map_frames(ks, self.gpu_cap(), |k| self.frame(comp, k), f) + // The cap is for the GPU's memory: once it has stopped for good, frames render on the + // CPU in parallel. + let gpu_live = || self.job.accel.is_none_or(|a| a.available()); + map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f) } /// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every @@ -453,6 +477,36 @@ mod tests { (out, max.load(Ordering::SeqCst)) } + #[test] + fn frames_render_in_parallel_once_the_gpu_stops() { + // Every frame after the GPU stopped falls back to the CPU: the cap no longer applies. + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| { + map_frames_while( + (0..24).collect(), + Some(1), + || live.load(Ordering::SeqCst), + |k| { + if k == 1 { + live.store(false, Ordering::SeqCst); + } + let c = now.fetch_add(1, Ordering::SeqCst) + 1; + if k >= 2 { + max.fetch_max(c, Ordering::SeqCst); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + now.fetch_sub(1, Ordering::SeqCst); + k * 10 + }, + |_, v| v, + ) + }); + assert_eq!(out, (0..24).map(|k| k * 10).collect::>()); + assert!(max.load(Ordering::SeqCst) > 1, "frames after the GPU stopped ran one at a time"); + } + #[test] fn capped_frames_keep_their_order() { let want: Vec = (0..40).map(|k| k * 10).collect(); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 252145c1..1cff8be0 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -317,6 +317,10 @@ impl Accelerator for Gpu { self.ctx.name.clone() } + fn available(&self) -> bool { + self.ctx.check_health().is_ok() + } + fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option { let img = self.render(r, comp, t); if img.is_none() { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6..4b215a23 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -456,6 +456,12 @@ pub trait Accelerator: Send + Sync { fn auto_pick(&self) -> Option<&AutoPick> { None } + /// Whether the accelerator can still render frames. `false` once it has stopped for good + /// (its device was lost or retired): every frame it is asked for falls back to the CPU, so + /// callers that limit frames in flight for it can stop doing so. + fn available(&self) -> bool { + true + } /// Deferred readbacks: where a GPU readback cannot be waited for (WebGPU in a browser /// worker), a frame renders in *passes*. A readback the accelerator does not have yet is /// started, the step returns a cheap placeholder and the pass is marked missed From 295e2a3837047f13ba6e781bcdd34525c91bafb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:38:36 +0200 Subject: [PATCH 086/185] GPU: stop drawing a frame once the device has failed --- crates/gpu/src/walk.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index e9541229..b1209070 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -73,6 +73,9 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut let levels = r.pipe().levels; let mut i = 0; while i < visible.len() { + // A device that ran out of memory (or was lost) on an earlier layer fails every later + // one: give the frame back to the CPU now instead of drawing the rest of the comp. + e.g.check_health().ok()?; if visible[i].is_3d() { let mut j = i; while j < visible.len() && visible[j].is_3d() { From 38d6fb2ed269743a59bdf1808135cfd7e5d29c06 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 01:42:17 +0200 Subject: [PATCH 087/185] #1509: Timeline effect rows show the effect's colour label Problem: effect colour labels (26.5) were drawn only on the Effect Controls header; the Timeline's effect rows looked unlabelled. Fix: an effect group row in the Timeline paints the label colour as a 4 px bar on its left edge (same look as the Effect Controls header) and registers `timeline.group..label` with the label's name for agents. Docs: gaps.md row 21 and ui-parity.md updated. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/timeline.rs | 10 +++++ .../ui-egui/tests/ui_timeline_effect_label.rs | 45 +++++++++++++++++++ docs/gaps.md | 2 +- docs/ui-parity.md | 2 +- 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_effect_label.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..fe3b1d62 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2400,6 +2400,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }, ); gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg); + // An effect's colour label (26.5): the same bar the Effect Controls header wears, + // on the row's left edge. + if let Some(lab) = + group.filter(|g| matches!(g.kind, GroupKind::Effect { .. })).and_then(|g| g.label).filter(|l| *l != effectcraft_engine::color::Label::None) + { + let [lr, lg, lb] = app.session.prefs.label_rgb(lab); + let bar = Rect::from_min_size(left.min, vec2(4.0, left.height())); + lp.rect_filled(bar, 0.0, Color32::from_rgb(lr, lg, lb)); + app.auto.add(&format!("timeline.group.{uid}.label"), bar, &app.session.prefs.label_name(lab)); + } let indent = cw.name + 6.0 + 14.0 * row.depth as f32; if *has_children { let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0)); diff --git a/crates/ui-egui/tests/ui_timeline_effect_label.rs b/crates/ui-egui/tests/ui_timeline_effect_label.rs new file mode 100644 index 00000000..deeee9e5 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_effect_label.rs @@ -0,0 +1,45 @@ +//! An effect's colour label (26.5) shows on its Timeline row as well as in Effect Controls. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::vec2; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "A", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [layer], "effect": "ec.blur.gaussian"})).unwrap()["effects"][0].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(layer)]; + s.state.selected_props = vec![(LayerId(layer), fx)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::Timeline); + app.ui.timeline.open_layers.insert(layer); + let effects = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))).and_then(|l| l.props.sub("effects")).map(|g| g.uid); + app.ui.timeline.open_groups.extend(effects); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + (h, layer, fx) +} + +/// The label bar appears on the effect's row once the effect has a label, carries the label's +/// name for agents, and goes away when the label is cleared. +#[test] +fn the_effect_row_wears_its_colour_label() { + let (mut h, layer, fx) = harness(); + let id = format!("timeline.group.{fx}.label"); + assert!(h.state().auto.find(&format!("timeline.group.{fx}.fx")).is_some(), "the effect's row is on screen"); + assert!(h.state().auto.find(&id).is_none(), "an unlabelled effect has no bar"); + + h.state_mut().session.execute("effect.setLabel", json!({"layer": layer, "effect": fx, "label": "Blue"})).unwrap(); + h.run_steps(3); + let blue = h.state().session.prefs.label_name(effectcraft_engine::color::Label::Blue); + assert_eq!(h.state().auto.find(&id).map(|e| e.label.clone()), Some(blue)); + + h.state_mut().session.execute("effect.setLabel", json!({"layer": layer, "effect": fx, "label": 0})).unwrap(); + h.run_steps(3); + assert!(h.state().auto.find(&id).is_none(), "clearing the label removes the bar"); +} diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fb..8a26bda6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -77,7 +77,7 @@ are grouped by area; the issue tracker has the rest. | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | -| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline shows no effect label, and the labels' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) | +| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline's effect rows wear the label as a colour bar on the left edge (#1509), but After Effects' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) | | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | | 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #425 keyframe confusion (#355 and #428 closed) | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668d..ea17bcd4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -22,7 +22,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed | | Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) | -| Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels: labels exist (a colour bar on the effect header, right-click Label menu, `effect.setLabel`; not in the Timeline), the rebuilt controls are not matched | +| Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels: labels exist (a colour bar on the effect header and on the effect's Timeline row, right-click Label menu, `effect.setLabel`), the rebuilt controls are not matched | | Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`, `ui_project_rows`; #227, #290, #407 fixed; a comp's Project row menu has Composition Settings… (#1058) | | Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; #467 (adding shortcuts on Linux), #428 and #336 are closed; no shortcuts for scripts | | Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); #552, #351 (multi-file drops) and #511 (crash dragging over the import dialog) are closed | From 217f0ffd59acdd7a5e073c21c2e25148129cc375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:27:41 +0200 Subject: [PATCH 088/185] GPU tests: print why the GPU didn't start, and fail with EFFECTCRAFT_REQUIRE_GPU=1 instead of skipping --- crates/gpu/src/tests.rs | 29 ++++++++++++++++++++--------- crates/gpu/src/tests_adv3d.rs | 10 ++++++---- crates/gpu/src/tests_deferred.rs | 20 ++++++++++---------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 696402d6..84f64a6d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -49,18 +49,29 @@ pub(crate) fn hold_gpu_lock() { /// The shared test device (the calling test holds [`hold_gpu_lock`]). pub(crate) fn gpu() -> Option<&'static Gpu> { - static G: OnceLock> = OnceLock::new(); + static G: OnceLock> = OnceLock::new(); hold_gpu_lock(); - G.get_or_init(|| { - let g = Gpu::headless(); - if g.is_none() { - eprintln!("effectcraft-gpu tests: no GPU adapter, skipping GPU comparisons"); - } else if let Some(g) = &g { - eprintln!("effectcraft-gpu tests: adapter {}", g.ctx.name); + let g = G.get_or_init(|| { + let g = Gpu::try_headless(); + match &g { + Ok(g) => eprintln!("effectcraft-gpu tests: adapter {}", g.ctx.name), + Err(e) => eprintln!("effectcraft-gpu tests: no GPU ({e}), skipping GPU comparisons"), } g - }) - .as_ref() + }); + require_gpu("effectcraft-gpu tests", g.as_ref()) +} + +/// `g`, or `None` (the GPU tests skip) when there is no GPU, unless `EFFECTCRAFT_REQUIRE_GPU=1` +/// (as in `tests_init`): then a GPU that didn't start fails the test, with why. +pub(crate) fn require_gpu(tests: &str, g: Result) -> Option { + match g { + Ok(g) => Some(g), + Err(e) if std::env::var("EFFECTCRAFT_REQUIRE_GPU").is_ok_and(|v| v == "1") => { + panic!("{tests}: EFFECTCRAFT_REQUIRE_GPU=1 but GPU initialization failed: {e}") + } + Err(_) => None, + } } /// The test device is on wgpu's OpenGL backend (Mesa's llvmpipe on the FreeBSD CI job). diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index ee99c5c0..869c60aa 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -17,11 +17,13 @@ use crate::Gpu; /// A device of the test's own (the test holds [`crate::tests::hold_gpu_lock`]). fn gpu() -> Option { crate::tests::hold_gpu_lock(); - let g = Gpu::headless(); - if g.is_none() { - eprintln!("effectcraft-gpu adv3d tests: no GPU adapter, skipping"); + match Gpu::try_headless() { + Ok(g) => Some(g), + Err(e) => { + eprintln!("effectcraft-gpu adv3d tests: no GPU ({e}), skipping"); + crate::tests::require_gpu("effectcraft-gpu adv3d tests", Err(&e)) + } } - g } fn add(p: &mut Project, cid: ItemId, src: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) { diff --git a/crates/gpu/src/tests_deferred.rs b/crates/gpu/src/tests_deferred.rs index e798bd8f..b6e43b55 100644 --- a/crates/gpu/src/tests_deferred.rs +++ b/crates/gpu/src/tests_deferred.rs @@ -17,19 +17,19 @@ use crate::tests::{Pattern, Scene, diff, n, set, tolerance, v3}; /// The device, held exclusively: deferred state is per device and a frame renders at a time /// (a frame worker renders one frame at a time too). pub(crate) fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'static, ()>)> { - static G: OnceLock> = OnceLock::new(); + static G: OnceLock> = OnceLock::new(); static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); // Also no other GPU test while it runs (see `hold_gpu_lock`). crate::tests::hold_gpu_lock(); - let g = G - .get_or_init(|| { - let g = pollster::block_on(Gpu::request_deferred()).map_err(|e| eprintln!("deferred gpu tests skipped: {e}")).ok(); - if let Some(g) = &g { - eprintln!("deferred gpu tests: adapter {}", g.ctx.name); - } - g - }) - .as_ref()?; + let g = G.get_or_init(|| { + let g = pollster::block_on(Gpu::request_deferred()); + match &g { + Ok(g) => eprintln!("deferred gpu tests: adapter {}", g.ctx.name), + Err(e) => eprintln!("deferred gpu tests: no GPU ({e}), skipping"), + } + g + }); + let g = crate::tests::require_gpu("deferred gpu tests", g.as_ref())?; Some((g, LOCK.lock().unwrap_or_else(|e| e.into_inner()))) } From c280506ba0261cc1c34656857907d18410f4cdcd Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 14 Oct 2026 20:51:09 -0600 Subject: [PATCH 089/185] fix(config): isolate atomic writes and preserve long filenames Create each atomic write through its own exclusively created temporary sibling and keep its open file handle until writing finishes. Concurrent writers publish complete files without truncating or following an existing temporary entry; failed writes or renames clean up their own staging file. Keep the temporary basename bounded independently of the destination name so previously supported long project filenames still save. Retain the concurrent-writer and symlink regressions and add a 251-byte filename overwrite regression that checks exact bytes and staging cleanup. Fixes #1233 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/config.rs | 46 ++++++++++++++------ crates/engine/src/tests_settings.rs | 66 +++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index c333c973..00e7377b 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -164,32 +164,50 @@ impl ConfigStore for DirConfig { } } -/// The temporary file an atomic write of `path` goes through. -pub fn temp_path(path: &Path) -> PathBuf { - let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); - name.push(".tmp"); - path.with_file_name(name) +/// A new, empty temporary file next to `path` for an atomic write of it, and its path. The name +/// is unique to this process and this write (`.effectcraft--.tmp`) and the file is created +/// exclusively, so two writers never share one and nothing already at that name (a file, a +/// symbolic link) is opened or followed (#1233). It ends in `.tmp`, so a file left by a hard +/// kill never counts as an auto-save. Its bounded name does not include the destination's name, +/// so a valid long destination name still fits the filesystem's component limit. +fn create_temp(path: &Path) -> std::io::Result<(std::fs::File, PathBuf)> { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let mut last = None; + for _ in 0..16 { + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let tmp = path.with_file_name(format!(".effectcraft-{}-{n}.tmp", std::process::id())); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(f) => return Ok((f, tmp)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e), + Err(e) => return Err(e), + } + } + Err(last.unwrap_or_else(|| std::io::Error::other("no free temporary file name"))) } /// Write `data` to `path` atomically: write a temporary file next to it, flush it to disk, then /// rename it over `path`. A crash at any point leaves either the old file or the new one, never a -/// torn file. +/// torn file, and concurrent writers each publish a whole file of their own. pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { - atomic_write_with(path, data, |tmp, data| { + atomic_write_with(path, data, |f, data| { use std::io::Write; - let mut f = std::fs::File::create(tmp)?; f.write_all(data)?; f.sync_all() }) } -/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A -/// failed write removes the temporary file and leaves `path` untouched. -pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> { - let tmp = temp_path(path); - if let Err(e) = write(&tmp, data) { +/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write); it is +/// given the temporary file, already created. A failed write or rename removes the temporary +/// file and leaves `path` untouched. +pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&mut std::fs::File, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> { + let (mut f, tmp) = create_temp(path)?; + let written = write(&mut f, data); + // Closed before the rename (Windows can't rename an open file). + drop(f); + if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, path)) { let _ = std::fs::remove_file(&tmp); return Err(e); } - std::fs::rename(&tmp, path) + Ok(()) } diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 458c886f..65e39b46 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::json; -use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path}; +use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with}; use crate::prefs::{PREFS_FILE, Prefs}; use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize}; use crate::{Session, autosave}; @@ -548,6 +548,66 @@ fn frontend_commands_are_bindable_with_panel_scope() { // ---------------------------------------------------------------- auto-save / recovery +/// The names in `d`, sorted. +fn names(d: &std::path::Path) -> Vec { + let mut v: Vec = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + v.sort(); + v +} + +#[cfg(unix)] +#[test] +fn atomic_write_overwrites_a_long_valid_filename() { + let d = tmp("atomic-long-name"); + // The old `.tmp` fits a 255-byte component limit for this 251-byte project name. + // Including the full name plus a process id and counter made an otherwise valid save fail. + let name = format!("{}.ecproj", "x".repeat(244)); + assert_eq!(name.len(), 251); + let f = d.join(&name); + std::fs::write(&f, b"original project").unwrap(); + let replacement = b"replacement project\0bytes\xff"; + atomic_write(&f, replacement).unwrap(); + assert_eq!(std::fs::read(&f).unwrap(), replacement); + assert_eq!(names(&d), [name], "no temporary file is left after overwriting the project"); + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn concurrent_atomic_writes_each_publish_a_whole_file() { + // Writers of the same file used to share one `.tmp`: they truncated each other's data and + // the later rename failed once the first had moved it away (#1233). + let d = tmp("atomic-concurrent"); + let f = d.join("shared.ecproj"); + for round in 0..10 { + let contents: Vec> = (0..8u8).map(|w| vec![b'a' + w; 256 * 1024 + usize::from(w) * 1000]).collect(); + std::thread::scope(|sc| { + for c in &contents { + let f = &f; + sc.spawn(move || atomic_write(f, c).expect("every writer succeeds")); + } + }); + let got = std::fs::read(&f).unwrap(); + assert!(contents.contains(&got), "round {round}: the file is one writer's whole data ({} bytes)", got.len()); + assert_eq!(names(&d), ["shared.ecproj"], "round {round}: no temporary file is left"); + } + let _ = std::fs::remove_dir_all(d); +} + +#[cfg(unix)] +#[test] +fn atomic_write_does_not_follow_a_link_at_the_old_temp_name() { + let d = tmp("atomic-link"); + let f = d.join("p.ecproj"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"keep").unwrap(); + // The temporary name used to be `.tmp`, opened with File::create (following links). + std::os::unix::fs::symlink(&victim, d.join("p.ecproj.tmp")).unwrap(); + atomic_write(&f, b"new project").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep", "the link's target is untouched"); + assert_eq!(std::fs::read(&f).unwrap(), b"new project"); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn atomic_write_survives_a_crash_mid_write() { let d = tmp("atomic"); @@ -555,12 +615,12 @@ fn atomic_write_survives_a_crash_mid_write() { atomic_write(&f, b"version 1").unwrap(); // The writer dies halfway through the temporary file. let r = atomic_write_with(&f, b"version 2 is much longer", |tmp, data| { - std::fs::write(tmp, &data[..5])?; + std::io::Write::write_all(tmp, &data[..5])?; Err(std::io::Error::other("power cut")) }); assert!(r.is_err()); assert_eq!(std::fs::read(&f).unwrap(), b"version 1"); - assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up"); + assert_eq!(names(&d), ["p.ecproj"], "the partial temp file is cleaned up"); // A temp file left by a hard kill doesn't count as an auto-save. std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap(); assert!(autosave::existing(&d, "p").is_empty()); From 2c08f675c22fc9fbf9a300a60deadee9944ce3c4 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 14 Oct 2026 21:15:40 -0600 Subject: [PATCH 090/185] fix(engine): preserve edits when applying background analyses Merge each offloaded property-group result against the snapshot the job started from. Preserve disjoint live edits, group identity and child changes; refuse conflicting results without changing the document or undo/redo history. Record an apply rejection on its originating job so completion logs and waiting control commands report failure. Keep concurrent jobs' outcomes separate and retain the correct completion position when the bounded log rolls over. Add actual-worker regressions for conflicts, disjoint edits, concurrent analyses and undo/redo, plus control completion/error regressions. Fixes #1249 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 3 + crates/engine/src/offload.rs | 180 ++++++++++++++++++++++++- crates/engine/src/tests_mask_warp.rs | 156 +++++++++++++++++++++ crates/ui-egui/src/control.rs | 17 ++- crates/ui-egui/tests/ui_web_offload.rs | 42 ++++++ 5 files changed, 388 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 80956687..96e09b57 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -495,6 +495,8 @@ pub struct Session { /// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one /// (`wait: true` over the control channel in the browser) learns its outcome. pub offload_log: Vec<(offload::JobKind, Option)>, + /// Number of older completion entries evicted from the bounded offload log. + pub offload_log_start: usize, /// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`]. pub tasks: Vec, /// Finished tasks, newest last (Progress panel, `jobs.list`). @@ -598,6 +600,7 @@ impl Default for Session { offload: None, offloaded: vec![], offload_log: vec![], + offload_log_start: 0, browser: None, storage: None, tasks: vec![], diff --git a/crates/engine/src/offload.rs b/crates/engine/src/offload.rs index 561ebe6c..bd1fec8a 100644 --- a/crates/engine/src/offload.rs +++ b/crates/engine/src/offload.rs @@ -171,6 +171,20 @@ pub enum JobKind { } impl JobKind { + /// The corresponding `jobs.list` id, also used by waiting control commands. + pub fn job_id(self) -> &'static str { + match self { + JobKind::Render => "render", + JobKind::Warp => "warp", + JobKind::WarpPlan => "warpPlan", + JobKind::Camera => "camera", + JobKind::RotoFreeze | JobKind::RotoPropagate => "roto", + JobKind::Track => "track", + JobKind::MaskTrack => "maskTrack", + JobKind::ContentFill => "contentFill", + } + } + /// The undo step an analysis result becomes. pub fn label(self) -> &'static str { match self { @@ -699,6 +713,9 @@ pub struct OffloadedJob { /// Segmentations received (Roto Brush propagation). pub segs: u64, started: web_time::Instant, + /// The analysed group as the job started: its result is merged with what changed since + /// ([`merge_group`]). + base: Option>, } /// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]). @@ -730,6 +747,7 @@ impl Session { } let id = next_id(); let inbox = Arc::new(Inbox::default()); + let base = self.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().map(Box::new); let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job }; off.start(req, inbox.clone())?; self.offloaded.push(OffloadedJob { @@ -742,6 +760,7 @@ impl Session { progress: OffloadProgress::default(), segs: 0, started: web_time::Instant::now(), + base, }); self.events.push(Event::ProjectChanged { revision: self.revision }); Ok(()) @@ -779,7 +798,7 @@ impl Session { j.progress.done = done; j.progress.total = total; } - WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)), + WorkerReply::Group { comp, layer, group, message } => results.push((j.id, j.kind, comp, layer, group, message, j.base.clone())), WorkerReply::Fill { plan, files } => fills.push((plan, files)), WorkerReply::WarpPlan { key, summary } => effectcraft_effects::warp_stab::store_summary(key, *summary), WorkerReply::Segs { segs } => { @@ -802,7 +821,7 @@ impl Session { Err(e) => self.events.push(Event::Toast { message: format!("Content-Aware Fill: {e}"), error: true }), } } - for (kind, comp, layer, group, message) in results { + for (id, kind, comp, layer, group, message, base) in results { let uid = group.uid; let r = self.edit(kind.label(), None, |p, _| { let g = p @@ -810,12 +829,31 @@ impl Session { .and_then(|c| c.layer_mut(layer)) .and_then(|l| l.props.find_group_mut(uid)) .ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?; - *g = *group; + // Edits made while the job ran stay (#1249): only what the analysis changed is + // taken from its result. + *g = match base { + Some(base) => merge_group(&base, g, &group).ok_or_else(|| { + crate::EngineError::Other(format!( + "“{}” was changed during the analysis where its result changes it too, so nothing was applied: analyse again", + g.name + )) + })?, + None => *group, + }; Ok(()) }); match r { Ok(()) => self.events.push(Event::Toast { message, error: false }), - Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }), + Err(e) => { + // Completion consumers read the job's error, not its toasts. Keep an apply + // rejection on the originating job; Done logs and reports it just like a + // WorkerReply::Failed, without failing another concurrent analysis. + if let Some(job) = self.offloaded.iter_mut().find(|j| j.id == id) { + job.progress.error = Some(e.to_string()); + } else { + self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }); + } + } } } let mut done = vec![]; @@ -830,6 +868,7 @@ impl Session { self.offload_log.push((kind, p.error.clone().or_else(|| p.cancelled.then(|| "stopped".to_string())))); if self.offload_log.len() > 32 { self.offload_log.remove(0); + self.offload_log_start = self.offload_log_start.saturating_add(1); } if let Some(e) = p.error { self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }); @@ -880,6 +919,76 @@ impl Session { } } +/// `live` if only it changed since `base`, `result` if only that did (or both the same way); +/// `None` when both changed it differently. +fn pick(base: &T, live: &T, result: &T) -> Option { + if live == base { + Some(result.clone()) + } else if result == base || result == live { + Some(live.clone()) + } else { + None + } +} + +fn node_uid(n: &effectcraft_project::Node) -> Uid { + match n { + effectcraft_project::Node::Prop(p) => p.uid, + effectcraft_project::Node::Group(g) => g.uid, + } +} + +/// Merge an analysis `result` into the `live` group (#1249). `base` is the group as the job +/// started. The group's own fields and each property are taken from whichever side changed +/// them, groups are merged child by child (matched by uid), and a child added or removed by one +/// side is added or removed. `None` when both sides changed the same field or property +/// differently, one side removed a child the other changed, or both reordered or added and +/// removed children differently: a conflict. +pub(crate) fn merge_group(base: &PropGroup, live: &PropGroup, result: &PropGroup) -> Option { + use effectcraft_project::Node; + if live == base { + return Some(result.clone()); + } + if result == base || result == live { + return Some(live.clone()); + } + let header = |g: &PropGroup| PropGroup { children: vec![], ..g.clone() }; + let mut out = pick(&header(base), &header(live), &header(result))?; + let ids = |g: &PropGroup| g.children.iter().map(node_uid).collect::>(); + let (bi, li, ri) = (ids(base), ids(live), ids(result)); + // The children's order and set: from the side that changed them. + let order = if li == bi { + ri + } else if ri == bi || ri == li { + li + } else { + return None; + }; + fn find(g: &PropGroup, u: Uid) -> Option<&effectcraft_project::Node> { + g.children.iter().find(|n| node_uid(n) == u) + } + // A child one side removed must be unchanged on the other, or that side's edit would be lost. + for u in bi.iter().filter(|u| !order.contains(u)) { + let b = find(base, *u); + if [find(live, *u), find(result, *u)].into_iter().flatten().any(|n| Some(n) != b) { + return None; + } + } + for u in order { + let node = match (find(base, u), find(live, u), find(result, u)) { + (Some(Node::Group(b)), Some(Node::Group(l)), Some(Node::Group(r))) => Node::Group(merge_group(b, l, r)?), + (Some(b), Some(l), Some(r)) => pick(b, l, r)?, + // Added by one side, or by both alike. + (None, Some(l), None) => l.clone(), + (None, None, Some(r)) => r.clone(), + (None, Some(l), Some(r)) if l == r => l.clone(), + _ => return None, + }; + out.children.push(node); + } + Some(out) +} + #[cfg(test)] mod tests { use super::*; @@ -892,6 +1001,69 @@ mod tests { a } + /// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c` + /// (uid 5). + fn group() -> PropGroup { + use effectcraft_keyframe::Value as KV; + use effectcraft_project::{Node, Property}; + let mut sub = PropGroup::new(4, "sub", "Sub"); + sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0)))); + let mut g = PropGroup::new(1, "fx", "Effect"); + g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0)))); + g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0)))); + g.children.push(Node::Group(sub)); + g + } + + fn set(g: &mut PropGroup, uid: Uid, v: f64) { + g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v); + } + + fn get(g: &PropGroup, uid: Uid) -> f64 { + match g.find(uid).unwrap().value { + effectcraft_keyframe::Value::Scalar(v) => v, + ref v => panic!("{v:?}"), + } + } + + #[test] + fn analysis_results_merge_with_edits_made_meanwhile() { + use effectcraft_project::Node; + let base = group(); + // The user changes `a` and switches the effect off; the analysis writes `b` and `c`. + let mut live = base.clone(); + set(&mut live, 2, 1.0); + live.enabled = false; + let mut result = base.clone(); + set(&mut result, 3, 2.0); + set(&mut result, 5, 3.0); + let m = merge_group(&base, &live, &result).unwrap(); + assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false)); + // Unchanged on either side: the other side wins whole. + assert_eq!(merge_group(&base, &base, &result).unwrap(), result); + assert_eq!(merge_group(&base, &live, &base).unwrap(), live); + // Both changed `b`, differently: a conflict (nothing is applied). The same way: fine. + let mut both = live.clone(); + set(&mut both, 3, 9.0); + assert!(merge_group(&base, &both, &result).is_none()); + set(&mut both, 3, 2.0); + assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0); + // The analysis adds a child (a result group) while the user edits `a`: both kept. + let mut added = result.clone(); + added.children.push(Node::Group(PropGroup::new(6, "out", "Output"))); + let m = merge_group(&base, &live, &added).unwrap(); + assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0); + // The analysis drops the sub-group the user didn't touch: dropped. + let mut dropped = result.clone(); + dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4)); + let m = merge_group(&base, &live, &dropped).unwrap(); + assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0); + // …but if the user changed something in it, that's a conflict. + let mut touched = live.clone(); + set(&mut touched, 5, 7.0); + assert!(merge_group(&base, &touched, &dropped).is_none()); + } + #[test] fn protocol_round_trips() { let mut s = Session::default(); diff --git a/crates/engine/src/tests_mask_warp.rs b/crates/engine/src/tests_mask_warp.rs index e08ce2e6..137feb1c 100644 --- a/crates/engine/src/tests_mask_warp.rs +++ b/crates/engine/src/tests_mask_warp.rs @@ -412,6 +412,162 @@ impl Deferred { } } +/// #1249: a setting changed while the analysis runs in a worker survives its result, which +/// used to replace the whole effect with the copy the job started from. +#[test] +fn an_edit_made_during_an_offloaded_analysis_survives_its_result() { + let (mut s, clip, solid) = setup(shaky); + s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [clip.0]})).unwrap(); + s.execute("effect.apply", json!({"effect": "Warp Stabilizer"})).unwrap(); + set_param(&mut s, clip, "stabilization/smoothness", json!(37.5)); + let off = std::sync::Arc::new(Deferred::default()); + s.offload = Some(off.clone()); + s.execute_checked("warp.analyze", json!({"layer": clip.0})).unwrap(); + // While the job runs: another setting of the same effect. + set_param(&mut s, clip, "stabilization/smoothness", json!(12.5)); + let fx = |s: &Session| s.active_comp().unwrap().layer(clip).unwrap().props.prop("effects/#1/stabilization/smoothness").unwrap().value.clone(); + off.run(s.footage.clone()); + assert!(s.poll_offload()); + assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. })), "applied without a conflict"); + let st = s.execute("warp.status", json!({})).unwrap(); + assert_eq!(st["analyzed"], true, "the analysis landed: {st}"); + assert_eq!(fx(&s), KV::Scalar(12.5), "the edit made during the analysis stays"); + // One undo step takes the analysis back and keeps the edit. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(fx(&s), KV::Scalar(12.5)); + assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false); +} + +fn point_track_pose(f: u32) -> ([f64; 2], f64, f64) { + ([150.0 + 2.0 * f as f64, 110.0 + f as f64], 0.0, 1.0) +} + +/// Hold an actual point-tracking worker request so commands can edit its target before the +/// serialized worker reply is delivered. +fn held_point_track() -> (Session, LayerId, Uid, std::sync::Arc) { + let (mut s, clip, solid) = setup(patch_frames(point_track_pose)); + s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [clip.0]})).unwrap(); + let tracker = s.execute("track.motion", json!({})).unwrap()["tracker"].as_u64().unwrap(); + s.execute("track.setPoint", json!({"point": 1, "center": point_track_pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap(); + let off = std::sync::Arc::new(Deferred::default()); + s.offload = Some(off.clone()); + s.execute_checked("track.analyze", json!({"direction": "forward"})).unwrap(); + assert_eq!(off.kinds(), [crate::offload::JobKind::Track]); + (s, clip, tracker, off) +} + +fn tracked_center(s: &Session, clip: LayerId, tracker: Uid) -> Uid { + s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().track_points().next().unwrap().get("featureCenter").unwrap().uid +} + +#[test] +fn offloaded_conflict_preserves_state_and_reports_completion_failure() { + let (mut s, clip, tracker, off) = held_point_track(); + let center = tracked_center(&s, clip, tracker); + s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap(); + s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Redo survives"})).unwrap(); + assert!(s.undo()); + let before = s.project.clone(); + let state = serde_json::to_value(&s.state).unwrap(); + let steps = |s: &Session| { + let entries = + |v: &[(String, std::sync::Arc)]| v.iter().map(|(label, p)| (label.clone(), p.to_json())).collect::>(); + ( + entries(&s.history.undo), + entries(&s.history.redo), + s.history.branches.iter().map(|b| (b.parent.to_json(), entries(&b.steps))).collect::>(), + s.history.merge_key.clone(), + ) + }; + let history = steps(&s); + let inbox = s.offloaded(crate::offload::JobKind::Track).unwrap().inbox.clone(); + off.run(s.footage.clone()); + let (done, replies): (Vec<_>, Vec<_>) = inbox.take().into_iter().partition(|r| matches!(r, crate::offload::WorkerReply::Done)); + assert_eq!(done.len(), 1); + for reply in replies { + inbox.push(reply); + } + s.drain_events(); + assert!(s.poll_offload()); + let pending = s.offloaded(crate::offload::JobKind::Track).unwrap(); + assert!(pending.progress.error.as_ref().is_some_and(|e| e.contains("nothing was applied"))); + assert!(!pending.progress.finished); + assert!(s.offload_log.is_empty(), "the error is kept before completion is logged"); + // The current completion remains identifiable after the bounded log evicts an old entry. + s.offload_log = vec![(crate::offload::JobKind::Warp, None); 32]; + for reply in done { + inbox.push(reply); + } + assert!(s.poll_offload()); + assert!(std::sync::Arc::ptr_eq(&s.project, &before), "a rejected result changes no document content"); + assert_eq!(serde_json::to_value(&s.state).unwrap(), state); + assert_eq!(steps(&s), history); + let errors: Vec<_> = s + .drain_events() + .into_iter() + .filter_map(|e| match e { + crate::Event::Toast { error: true, message } => Some(message), + _ => None, + }) + .collect(); + assert_eq!(errors.len(), 1, "the apply rejection is reported once: {errors:?}"); + assert!(errors[0].contains("nothing was applied")); + assert_eq!(s.offload_log.len(), 32); + assert_eq!(s.offload_log_start, 1); + let (kind, error) = s.offload_log.last().unwrap(); + assert_eq!(*kind, crate::offload::JobKind::Track); + assert!(error.as_ref().is_some_and(|e| e.contains("nothing was applied")), "a rejected result must report failure to JobWaiter"); + assert!(s.redo()); + assert_eq!(s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().name, "Redo survives"); +} + +#[test] +fn offloaded_disjoint_edit_reports_success_and_keeps_one_undo_step() { + let (mut s, clip, tracker, off) = held_point_track(); + s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Live track"})).unwrap(); + s.execute("prop.setGroupEnabled", json!({"layer": clip.0, "prop": tracker, "value": false})).unwrap(); + let undo = s.history.undo.len(); + off.run(s.footage.clone()); + s.drain_events(); + assert!(s.poll_offload()); + let layer = s.active_comp().unwrap().layer(clip).unwrap(); + let track = layer.props.find_group(tracker).unwrap(); + assert_eq!(track.name, "Live track"); + assert!(!track.enabled); + assert_eq!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.len(), 25); + assert_eq!(s.history.undo.len(), undo + 1); + assert_eq!(s.offload_log.last(), Some(&(crate::offload::JobKind::Track, None))); + assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. }))); + let applied = s.project.to_json(); + assert!(s.undo()); + let track = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap(); + assert_eq!(track.name, "Live track"); + assert!(!track.enabled); + assert!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty()); + assert!(s.redo()); + assert_eq!(s.project.to_json(), applied); +} + +#[test] +fn offloaded_conflict_does_not_fail_a_concurrent_analysis() { + let (mut s, clip, tracker, off) = held_point_track(); + let effect = s.execute("effect.apply", json!({"effect": "Warp Stabilizer", "layers": [clip.0]})).unwrap()["effects"][0].as_u64().unwrap(); + s.execute_checked("warp.analyze", json!({"layer": clip.0, "effect": effect})).unwrap(); + assert_eq!(off.kinds(), [crate::offload::JobKind::Track, crate::offload::JobKind::Warp]); + let center = tracked_center(&s, clip, tracker); + s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap(); + off.run(s.footage.clone()); + assert!(s.poll_offload()); + let track = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Track).unwrap(); + assert!(track.1.as_ref().is_some_and(|e| e.contains("nothing was applied"))); + let warp = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Warp).unwrap(); + assert!(warp.1.is_none(), "the other analysis completed successfully: {warp:?}"); + assert_eq!(s.execute("warp.status", json!({"layer": clip.0, "effect": effect})).unwrap()["analyzed"], true); + assert!(s.active_comp().unwrap().layer(clip).unwrap().props.find(center).unwrap().keys.is_empty()); +} + /// M13.32: where jobs run elsewhere (the browser), `warp.status` never solves the /// stabilization plan on the page's thread (seconds for a long Subspace Warp clip): the /// analysis job sends the plan's summary with its result, and a settings change has the plan diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bb45d110..15493ad7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -68,8 +68,8 @@ pub struct JobWaiter { pub result: Value, /// Ids of the jobs it started (`jobs.list` ids: `render`, `track`, `roto`…). pub jobs: Vec, - /// Length of [`effectcraft_engine::Session::offload_log`] when it started. - pub log_len: usize, + /// Absolute completion-log position when it started (including evicted entries). + pub log_cursor: usize, } /// Commands that block with `wait` (and its default). @@ -86,7 +86,7 @@ fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result = app.session.jobs().into_iter().map(|j| j.id).collect(); - let log_len = app.session.offload_log.len(); + let log_cursor = app.session.offload_log_start.saturating_add(app.session.offload_log.len()); let mut p = params.clone(); p["wait"] = json!(false); let result = app.session.execute_checked(id, p).map_err(|e| e.to_string())?; @@ -94,7 +94,7 @@ fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result Value { let s = &mut app.session; s.poll_render(); - let errors: Vec = s.offload_log.iter().skip(self.log_len).filter_map(|(_, e)| e.clone()).collect(); - if let Some(e) = errors.first() { + let error = s + .offload_log + .iter() + .skip(self.log_cursor.saturating_sub(s.offload_log_start)) + .filter(|(kind, _)| self.jobs.iter().any(|id| id == kind.job_id())) + .find_map(|(_, e)| e.as_ref()); + if let Some(e) = error { return json!({"ok": false, "error": e}); } let mut r = self.result; diff --git a/crates/ui-egui/tests/ui_web_offload.rs b/crates/ui-egui/tests/ui_web_offload.rs index cc49325f..67ec6a9f 100644 --- a/crates/ui-egui/tests/ui_web_offload.rs +++ b/crates/ui-egui/tests/ui_web_offload.rs @@ -219,3 +219,45 @@ fn waiting_commands_reply_when_their_offloaded_job_ends() { let v = reply.try_recv().expect("immediate reply"); assert_eq!(v["result"]["rendering"], true, "{v}"); } + +/// Completion errors belong to the waiting command's jobs, including when several +/// different analyses complete in the same UI frame. +#[test] +fn waiting_commands_report_only_their_own_completion_errors() { + use effectcraft_engine::offload::JobKind; + use effectcraft_ui_egui::control::JobWaiter; + + for (kind, job_id) in [ + (JobKind::Render, "render"), + (JobKind::Warp, "warp"), + (JobKind::WarpPlan, "warpPlan"), + (JobKind::Camera, "camera"), + (JobKind::RotoFreeze, "roto"), + (JobKind::RotoPropagate, "roto"), + (JobKind::Track, "track"), + (JobKind::MaskTrack, "maskTrack"), + (JobKind::ContentFill, "contentFill"), + ] { + let other = if kind == JobKind::Track { JobKind::Warp } else { JobKind::Track }; + let mut app = EffectcraftApp::new(Session::default()); + app.session.offload_log = vec![(kind, Some("old error".into())), (other, Some("unrelated failure".into())), (kind, None)]; + let waiter = || JobWaiter { command: "analysis".into(), params: json!({}), result: json!({"running": true}), jobs: vec![job_id.into()], log_cursor: 1 }; + let result = waiter().finish(&mut app); + assert_eq!(result, json!({"ok": true, "result": {"running": false}}), "{kind:?}"); + app.session.offload_log.push((kind, Some("nothing was applied".into()))); + let result = waiter().finish(&mut app); + assert_eq!(result, json!({"ok": false, "error": "nothing was applied"}), "{kind:?}"); + } +} + +#[test] +fn waiting_command_sees_its_error_after_the_completion_log_rolls_over() { + use effectcraft_engine::offload::JobKind; + use effectcraft_ui_egui::control::JobWaiter; + let mut app = EffectcraftApp::new(Session::default()); + app.session.offload_log = vec![(JobKind::Warp, None); 31]; + app.session.offload_log.push((JobKind::Track, Some("nothing was applied".into()))); + app.session.offload_log_start = 1; + let waiter = JobWaiter { command: "track.analyze".into(), params: json!({}), result: json!({}), jobs: vec!["track".into()], log_cursor: 32 }; + assert_eq!(waiter.finish(&mut app), json!({"ok": false, "error": "nothing was applied"})); +} From 749e810174e9f10785f94532b8769d2ad6c2a893 Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Thu, 15 Oct 2026 00:26:44 +0200 Subject: [PATCH 091/185] Effects: Black & White keeps faint colour noise in shadows faint (#1463) Black & White offset each pixel's luminance by (hue weight - 0.5) times its HSL saturation. HSL saturation is near 1 for any tinted near-black pixel, so the code or two of colour noise in compressed footage's shadows came out as bright and black blocks, and a 32-bpc overbright such as (1.5, 0.5, 0.5) divided by zero and went black. The gray is now the pixel's smallest channel plus its chroma (largest - smallest channel) times the same hue-interpolated slider weight. Neutral pixels keep their value, a pure hue becomes its slider's percentage (Reds 40% turns pure red into 40% gray), noise moves the gray by no more than its own size, and nothing divides. The CPU effect and the GPU kernel change the same way. --- crates/effects/src/color_fx.rs | 73 ++++++++++++++++++++++++++-- crates/gpu/src/shaders/fx_color.wgsl | 5 +- crates/gpu/src/tests_fx_color.rs | 1 + 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index e431ff22..cf6698fe 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -326,20 +326,27 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { v.abs().powf(p).copysign(v) } +/// Black & White: a pixel's gray is its smallest channel plus its chroma (largest − smallest +/// channel) times the colour sliders' weight for its hue. A neutral pixel keeps its value, a pure +/// hue becomes its slider's percentage (Reds 40 % turns pure red into 40 % gray), and the gray +/// never moves further from the smallest channel than the pixel's own chroma times the weight. +/// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted +/// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright +/// and black blocks (#1463), and 32-bpc overbrights divided by zero. fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")] .map(|v| v as f32 / 100.0); let tint_on = ctx.params.b("tint"); let tc = ctx.params.color("tintColor"); b.img.map_straight(|c| { - let (h, s, _) = rgb_to_hsl(c[0], c[1], c[2]); + let (h, _, _) = rgb_to_hsl(c[0], c[1], c[2]); // Interpolate the six hue weights around the colour wheel. let hp = h * 6.0; let i = hp.floor() as usize % 6; let f = hp - hp.floor(); let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f; - let base = luminance(c[0], c[1], c[2]); - let gray = (base + (wt - 0.5) * s * 0.6).max(0.0); + let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); + let gray = (min + (max - min) * wt).max(0.0); if tint_on { let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0)); @@ -1220,6 +1227,66 @@ mod tests { assert!(go(0.0, 100.0, 64) >= 30); } + /// #1463: Black & White's sliders set the gray of each pure hue and blend between + /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright + /// colour stays finite (it used to divide by zero). + #[test] + fn black_white_sliders_set_the_gray_of_each_hue() { + let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]); + let gray = |v: f32| [v, v, v, 1.0]; + // The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%). + let hues = [ + ([1.0, 0.0, 0.0], 0.4), + ([1.0, 1.0, 0.0], 0.6), + ([0.0, 1.0, 0.0], 0.4), + ([0.0, 1.0, 1.0], 0.6), + ([0.0, 0.0, 1.0], 0.2), + ([1.0, 0.0, 1.0], 0.8), + // Half as bright, half the gray; orange is halfway from Reds to Yellows. + ([0.5, 0.0, 0.0], 0.2), + ([1.0, 0.5, 0.0], 0.5), + // Neutral pixels keep their value. + ([0.3, 0.3, 0.3], 0.3), + ([4.0, 4.0, 4.0], 4.0), + // An overbright red: its smallest channel plus its chroma at Reds' 40 %. + ([1.5, 0.5, 0.5], 0.9), + ]; + for (c, want) in hues { + let o = bw(&[], c); + assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}"); + } + assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5)); + assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5)); + assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5)); + } + + /// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or + /// two of colour noise, which Black & White turned into bright and black blocks (the HSL + /// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the + /// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's. + #[test] + fn black_white_keeps_faint_colour_noise_faint() { + // A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one. + for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] { + let (w, h) = (64u32, 4u32); + let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h)); + for y in 0..h { + for x in 0..w { + // A dark ramp up to a quarter gray, in whole steps... + let v = (x as f32 / w as f32 * 0.25 / step).round() * step; + // ...and up to ±2 steps of noise per channel, mixed differently in each pixel. + let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step; + clean.set(x, y, [v, v, v, 1.0]); + noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]); + } + } + let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img; + let (a, b) = (bw(clean), bw(noisy)); + let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max); + assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step); + } + } + #[test] fn levels_clips_black_and_white_independently() { let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0]; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index c7d6010e..6b8641dd 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -154,8 +154,9 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { let i = u32(max(floor(hp), 0.0)) % 6u; let f = hp - floor(hp); let wt = w[i] + (w[(i + 1u) % 6u] - w[i]) * f; - let base = luminance(c); - let gray = max(base + (wt - 0.5) * hsl.y * 0.6, 0.0); + let mx = max(max(c.x, c.y), c.z); + let mn = min(min(c.x, c.y), c.z); + let gray = max(mn + (mx - mn) * wt, 0.0); var o = vec3(gray); if (P.u[0].y != 0u) { o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0)); diff --git a/crates/gpu/src/tests_fx_color.rs b/crates/gpu/src/tests_fx_color.rs index 28e80278..a3fe3f65 100644 --- a/crates/gpu/src/tests_fx_color.rs +++ b/crates/gpu/src/tests_fx_color.rs @@ -29,6 +29,7 @@ fn color_balance_vibrance_tritone_mixer() { fn black_white_colorama_selective_color() { effect_case("ec.color.blackwhite", &[]); effect_case("ec.color.blackwhite", &[("reds", n(120.0)), ("blues", n(-50.0)), ("cyans", n(200.0)), ("tint", on())]); + effect_case("ec.color.blackwhite", &[("yellows", n(300.0)), ("greens", n(-200.0)), ("magentas", n(0.0))]); effect_case("ec.color.colorama", &[]); effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]); effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]); From 7a9c2a8c401271aa3917318400fda80fe5416b00 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:44:19 -0700 Subject: [PATCH 092/185] Fonts: honor Fontconfig configuration search paths Assisted-by: GPT-6 Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- crates/text/src/font_dirs.rs | 147 ++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 4 deletions(-) diff --git a/crates/text/src/font_dirs.rs b/crates/text/src/font_dirs.rs index 39714cad..8004a62c 100644 --- a/crates/text/src/font_dirs.rs +++ b/crates/text/src/font_dirs.rs @@ -19,6 +19,8 @@ pub(crate) struct FontEnv { pub xdg_config_home: Option, /// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones. pub fontconfig_file: Option, + /// `FONTCONFIG_PATH`: ordered search folders, before the standard configuration folders. + pub fontconfig_path: Option, /// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists). pub fontconfig_roots: Vec, /// Folders scanned if they exist (the usual system folders and the Flatpak host folders). @@ -34,6 +36,7 @@ impl FontEnv { xdg_data_dirs: var("XDG_DATA_DIRS"), xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from), fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from), + fontconfig_path: var("FONTCONFIG_PATH"), fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(), fixed: [ "/usr/share/fonts", @@ -87,10 +90,7 @@ const MAX_FILE_BYTES: u64 = 1 << 20; /// The font folders named by the fontconfig configuration. pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() }; - let root = match &env.fontconfig_file { - Some(f) => Some(f.clone()), - None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(), - }; + let root = fontconfig_root(env); if let Some(root) = root { st.file(&root, 0); } @@ -102,6 +102,40 @@ pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { st.out } +/// Select the main file as fontconfig does: absolute names bypass the search path; +/// relative names (or `fonts.conf`) use FONTCONFIG_PATH, then the standard folders. +fn fontconfig_root(env: &FontEnv) -> Option { + if let Some(file) = &env.fontconfig_file + && file.is_absolute() + { + return Some(file.clone()); + } + if env.fontconfig_file.is_none() && env.fontconfig_path.is_none() { + return env.fontconfig_roots.iter().find(|p| readable_config(p)).cloned(); + } + let file = env.fontconfig_file.as_deref().unwrap_or_else(|| Path::new("fonts.conf")); + let mut dirs = Vec::new(); + if let Some(path) = &env.fontconfig_path { + let mut paths = std::env::split_paths(path).peekable(); + while let Some(mut dir) = paths.next() { + // Fontconfig ignores a final empty item; an interior empty item names `/`. + if dir.as_os_str().is_empty() { + if paths.peek().is_none() { + continue; + } + dir = PathBuf::from("/"); + } + dirs.push(dir); + } + } + dirs.extend(env.fontconfig_roots.iter().filter_map(|p| p.parent().map(Path::to_path_buf))); + dirs.into_iter().map(|dir| dir.join(file)).find(|p| readable_config(p)) +} + +fn readable_config(path: &Path) -> bool { + path.is_file() && std::fs::File::open(path).is_ok() +} + struct Reader<'a> { env: &'a FontEnv, out: Vec, @@ -371,4 +405,109 @@ mod tests { assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::>()); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn fontconfig_path_selects_the_first_existing_config_in_search_order() { + let root = temp("config-search"); + let first = root.join("first"); + let second = root.join("second"); + write(&first.join("fonts.conf"), "/first/fonts"); + write(&second.join("fonts.conf"), "/second/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(std::env::join_paths([root.join("missing"), first.clone(), second.clone()]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/first/fonts")]); + env.fontconfig_path = Some(std::env::join_paths([second, first]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/second/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_relative_selected_config_uses_the_search_path_and_keeps_relative_includes() { + let root = temp("selected-config"); + let config = root.join("config"); + write(&config.join("chosen.conf"), "conf.d"); + write(&config.join("conf.d/10-fonts.conf"), "../../fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(config.clone().into_os_string()); + env.fontconfig_file = Some(PathBuf::from("chosen.conf")); + assert_eq!(fontconfig_dirs(&env), [config.join("conf.d/../../fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn an_absolute_selected_config_bypasses_the_search_path() { + let root = temp("absolute-config"); + write(&root.join("search/fonts.conf"), "/wrong/fonts"); + write(&root.join("selected.conf"), "/selected/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(root.join("search").into_os_string()); + env.fontconfig_file = Some(root.join("selected.conf")); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + env.fontconfig_file = Some(root.join("missing.conf")); + assert!(fontconfig_dirs(&env).is_empty(), "an explicit missing file does not pick another config"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn configuration_search_falls_back_to_standard_folders() { + let root = temp("default-config"); + let config = root.join("etc/fonts"); + write(&config.join("fonts.conf"), "/default/fonts"); + write(&config.join("selected.conf"), "/selected/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(root.join("missing").into_os_string()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/default/fonts")]); + env.fontconfig_file = Some(PathBuf::from("selected.conf")); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + env.fontconfig_path = None; + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn configuration_search_preserves_non_unicode_folder_names() { + use std::os::unix::ffi::OsStrExt; + let root = temp("non-unicode-config"); + let config = root.join(std::ffi::OsStr::from_bytes(b"config-\xff")); + write(&config.join("fonts.conf"), "/configured/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(config.into_os_string()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/configured/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn configuration_search_skips_unreadable_files() { + use std::os::unix::fs::PermissionsExt; + struct Cleanup { + root: PathBuf, + file: PathBuf, + permissions: std::fs::Permissions, + } + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::set_permissions(&self.file, self.permissions.clone()); + let _ = std::fs::remove_dir_all(&self.root); + } + } + let root = temp("unreadable-config"); + let first = root.join("first/fonts.conf"); + let second = root.join("second/fonts.conf"); + write(&first, "/unreadable/fonts"); + write(&second, "/readable/fonts"); + let _cleanup = Cleanup { root: root.clone(), file: first.clone(), permissions: std::fs::metadata(&first).unwrap().permissions() }; + std::fs::set_permissions(&first, std::fs::Permissions::from_mode(0)).unwrap(); + if std::fs::File::open(&first).is_ok() { + eprintln!("skipped: this process can read mode-000 files"); + return; + } + let mut env = env_in(&root); + env.fontconfig_roots = vec![first.clone(), second.clone()]; + env.fontconfig_path = Some(std::env::join_paths([first.parent().unwrap(), second.parent().unwrap()]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]); + env.fontconfig_path = None; + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]); + } } From 2d216151c688fb74558128e8bd7943c8a0ba0ad1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 13:33:50 +0530 Subject: [PATCH 093/185] Import: Composition - Retain Layer Sizes for PDF / Illustrator files (#1536) The PDF / Illustrator import dialog now offers Composition - Retain Layer Sizes. Each file layer's footage is cropped to its artwork bounds (fills, stroke outlines, images, clips) and positioned to keep its place on the page. Decoding, continuous rasterisation, shape conversion and reload share one crop (effectcraft_pdf::layer_doc_with_sizes), so they agree. Also collapses a match guard in the STL reader for current stable clippy. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/file.rs | 6 +- crates/engine/src/footage_reload.rs | 7 ++ crates/engine/src/vector.rs | 26 +++++-- crates/host/tests/vector_import.rs | 108 ++++++++++++++++++++++----- crates/media/src/layered.rs | 2 +- crates/model/src/stl.rs | 6 +- crates/pdf/README.md | 4 +- crates/pdf/src/lib.rs | 21 ++++++ crates/pdf/src/tests.rs | 45 +++++++++++ crates/svg/src/bounds.rs | 64 ++++++++++++++++ crates/svg/src/lib.rs | 23 ++++++ crates/svg/src/raster.rs | 18 +---- crates/ui-egui/src/panels/forms.rs | 11 ++- crates/ui-egui/tests/ui_drag_drop.rs | 25 +++++++ 14 files changed, 314 insertions(+), 52 deletions(-) create mode 100644 crates/svg/src/bounds.rs diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 3b43eb63..501d1548 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -391,7 +391,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { Ok(b) if effectcraft_psd::is_psd(&b) => b, Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => { // PDF / Illustrator / EPS: one layer per file layer. - match import_vector_comp(s, &path, &b, page) { + match import_vector_comp(s, &path, &b, page, retain) { Ok((comp, made)) => { comps.push(comp); items.extend(made); @@ -540,7 +540,7 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retai /// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step). /// Returns (comp, items). -fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec)> { +fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32, retain: bool) -> Result<(u64, Vec)> { let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); if page > 0 { name = format!("{name} (Page {})", page + 1); @@ -549,7 +549,7 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }; let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs)); let (comp, folder, items) = s.edit("Import", None, |proj, st| { - let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?; + let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, retain, (rate, duration)).map_err(EngineError::Other)?; st.project_selection = vec![r.0]; Ok(r) })?; diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index a8f32a37..ca51069a 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -58,6 +58,12 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u Some((u32::try_from(found.index).ok()?, size)) } +fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> { + let bytes = s.services.read_file(&f.path).ok()?; + let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?; + (!doc.root.children.is_empty()).then(|| doc.pixel_size()) +} + /// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`). fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { let mut nf = f.clone(); @@ -89,6 +95,7 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { match &f.layer { // A smart object's size is its embedded file's, recorded at import. Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height), + Some(_) if crate::vector::is_vector(f) => (w, h) = vector_size(s, f).unwrap_or((f.width, f.height)), Some(l) => match psd_layer(s, &f.path, l) { Some((index, size)) => { if let Some(nl) = nf.layer.as_mut() { diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 23400953..3c349a7d 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -271,7 +271,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project:: Some( effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { - Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0, None => d, }) .map_err(|e| format!("{path}: {e}")), @@ -279,7 +279,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project:: } /// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the -/// page: one footage item per file layer (each showing only that layer, continuously +/// page: one footage item per file layer (optionally cropped to its artwork, continuously /// rasterisable), top layer first. Returns (comp, folder, items). pub fn import_vector_comp( proj: &mut Project, @@ -287,11 +287,12 @@ pub fn import_vector_comp( bytes: &[u8], name: &str, page: u32, - rate: effectcraft_time::FrameRate, - duration: effectcraft_time::Tick, + retain: bool, + timing: (effectcraft_time::FrameRate, effectcraft_time::Tick), ) -> Result<(ItemId, ItemId, Vec), String> { use effectcraft_color::Label; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer}; + let (rate, duration) = timing; let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?; let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF"); let (w, h) = doc.pixel_size(); @@ -301,24 +302,33 @@ pub fn import_vector_comp( let mut items = vec![]; let names = effectcraft_pdf::layer_names(&doc); for (i, lname) in names.iter().enumerate() { + let (size, offset) = if retain { + let (layer, offset) = effectcraft_pdf::layer_doc_with_sizes(&doc, i, true); + (layer.pixel_size(), offset) + } else { + ((w, h), [0.0, 0.0]) + }; let f = Footage { path: path.to_string(), kind: FootageKind::Still, - width: w, - height: h, + width: size.0, + height: size.1, pixel_aspect: 1.0, frame_rate: rate, has_video: true, alpha: AlphaMode::Straight, loop_count: 1, codec: codec.into(), - layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }), + layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: retain, ..Default::default() }), page, ..Default::default() }; let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f)); items.push(id); - let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None); + let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, size, None); + if retain && let Some(position) = l.props.prop_mut("transform/position") { + position.value = Value::Vec3([offset[0] + f64::from(size.0) / 2.0, offset[1] + f64::from(size.1) / 2.0, 0.0]); + } l.name = comp.unique_layer_name(lname); comp.layers.insert(0, l); } diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 44d2073f..49f8d950 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -260,23 +260,25 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { // Import As: Composition — one layer per file layer, top first, together like the page. let path = tmp("layers.ai"); std::fs::write(&path, &bytes).unwrap(); - let mut s = effectcraft_host::session(); - let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); - let cid = ItemId(r["comps"][0].as_u64().unwrap()); - let comp = s.project.comp(cid).unwrap(); - let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, vec!["Art", "Sky"]); - assert_eq!((comp.width, comp.height), (120, 80)); - let art = comp.layers[0].id.0; - let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); - let d = mean_diff(&img, &direct); - assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); - // Each layer's footage shows only its layer. - s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); - let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); - assert_eq!(sky.get(5, 5), img.get(5, 5)); - let p = sky.get(60, 30); - assert!(p[2] > p[0], "only sky under the art {p:?}"); + for import_as in ["composition", "compositionLayerSizes"] { + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["Art", "Sky"]); + assert_eq!((comp.width, comp.height), (120, 80)); + let art = comp.layers[0].id.0; + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + let d = mean_diff(&img, &direct); + assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); + // Each layer's footage shows only its layer. + s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); + let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + assert_eq!(sky.get(5, 5), img.get(5, 5)); + let p = sky.get(60, 30); + assert!(p[2] > p[0], "only sky under the art {p:?}"); + } } #[test] @@ -522,3 +524,75 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() { assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}"); } } + +fn retain_pdf(width: u32) -> Vec { + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> >> /Contents 4 0 R >>".into(), None), + ( + 4, + "<< >>".into(), + Some( + format!("/OC /L0 BDC q 20 15 {width} 20 re W n 1 0 0 rg 0 0 120 80 re f Q EMC /OC /L1 BDC 0 0 1 RG 4 w 70 40 m 100 40 l S EMC") + .into_bytes(), + ), + ), + (5, "<< /Type /OCG /Name (Box) >>".into(), None), + (6, "<< /Type /OCG /Name (Line) >>".into(), None), + ], + 1, + ) +} + +#[test] +fn ai_composition_retains_layer_sizes_and_positions() { + use effectcraft_project::{ItemKind, LayerSource, Project}; + use effectcraft_time::Tick; + + for name in ["retain.ai", "retain.pdf"] { + let path = tmp(name); + let bytes = retain_pdf(30); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + assert_eq!((comp.width, comp.height), (120, 80)); + assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["Line", "Box"]); + let mut ids = vec![]; + for (name, size, anchor, position) in [("Box", (30, 20), [15.0, 10.0], [35.0, 55.0]), ("Line", (30, 4), [15.0, 2.0], [85.0, 40.0])] { + let l = comp.layer_by_name(name).unwrap(); + let LayerSource::Footage { item } = l.source else { panic!("footage") }; + let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height), size, "{name} uses its artwork bounds"); + assert!(f.layer.as_ref().unwrap().layer_size); + assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), anchor); + assert_eq!(l.props.prop("transform/position").unwrap().value.as_vec2(), position); + let frame = s.footage.frame(item, f, Tick::ZERO).unwrap(); + assert_eq!((frame.width, frame.height), size); + let sharp = s.footage.vector_frame(item, f, 2.0).unwrap(); + assert_eq!((sharp.width, sharp.height), (size.0 * 2, size.1 * 2)); + assert!(sharp.get(1, 1)[3] > 0.99); + ids.push(item); + } + let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 120, 80, 1.0); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001); + let saved = s.project.to_json(); + s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap()); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "reopened project"); + let box_id = s.active_comp().unwrap().layer_by_name("Box").unwrap().id.0; + s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [box_id]})).unwrap(); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "shape conversion preserves placement"); + s.execute_checked("edit.undo", json!({})).unwrap(); + std::fs::write(&path, retain_pdf(40)).unwrap(); + s.execute_checked("file.reloadFootage", json!({"items": ids})).unwrap(); + let ItemKind::Footage(f) = &s.project.item(ids[0]).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height), (40, 20), "reload refreshes the layer's own dimensions"); + let frame = s.footage.frame(ids[0], f, Tick::ZERO).unwrap(); + assert_eq!((frame.width, frame.height), (40, 20)); + assert!(frame.get(39, 10)[0] > 0.99, "reload invalidates cached pixels"); + } +} diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 0fb40d81..0a70d1f7 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -28,7 +28,7 @@ pub fn vector_doc( Some( effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { - Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0, None => d, }) .map_err(|e| format!("{path}: {e}")), diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858a..34e9a7f0 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` diff --git a/crates/pdf/README.md b/crates/pdf/README.md index b7678b09..b44e3165 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -4,7 +4,9 @@ Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, wh documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG (`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one -layer per file layer (File ▸ Import ▸ Composition). +layer per file layer (File ▸ Import ▸ Composition). Composition - Retain Layer Sizes uses each +layer's artwork bounds and keeps its placement on the page. Images keep their rectangular bounds; +clipping paths constrain the bounds, while soft masks do not change them. Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file structure, filters, content streams, graphics state, colour spaces, functions, shadings, text, diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 39d15074..cf3f5ca6 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -194,6 +194,27 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc { d } +/// One layer, optionally cropped to its artwork, and its original document-space top-left. +pub fn layer_doc_with_sizes(doc: &Doc, index: usize, retain: bool) -> (Doc, [f64; 2]) { + let mut d = layer_doc(doc, index); + if !retain { + return (d, [0.0, 0.0]); + } + let page = kurbo::Rect::new(0.0, 0.0, doc.width, doc.height); + let bounds = d.artwork_bounds().map(|b| b.intersect(page)).filter(|b| b.width() > 0.0 && b.height() > 0.0); + let (x, y, w, h) = match bounds { + Some(b) => { + let (x, y) = (b.x0.floor(), b.y0.floor()); + (x, y, b.x1.ceil() - x, b.y1.ceil() - y) + } + None => (0.0, 0.0, 1.0, 1.0), + }; + d.width = w; + d.height = h; + d.root.transform = Affine::translate((-x, -y)) * d.root.transform; + (d, [x, y]) +} + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 57b7e044..e626ca40 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1020,3 +1020,48 @@ fn multiple_pages_and_calculator_shadings() { assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25)); assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2))); } + +#[test] +fn retained_layers_keep_strokes_clips_and_empty_layers() { + let doc = parse(&sample_pdf(false)).unwrap(); + for i in 0..layer_names(&doc).len() { + let full = layer_doc(&doc, i); + let (cropped, offset) = layer_doc_with_sizes(&doc, i, true); + let (w, h) = cropped.pixel_size(); + let image = effectcraft_svg::rasterize(&cropped, w, h, 1.0); + let original = effectcraft_svg::rasterize(&full, 200, 100, 1.0); + for y in 0..100 { + for x in 0..200 { + let a = image.get(x - offset[0] as i64, y - offset[1] as i64); + let b = original.get(x, y); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5), "layer {i} at {x},{y}: {a:?} vs {b:?}"); + } + } + } + let (empty, _) = layer_doc_with_sizes(&doc, usize::MAX, true); + assert_eq!(empty.pixel_size(), (1, 1)); +} + +#[test] +fn retained_layers_measure_transformed_artwork_without_rasterizing() { + let doc = effectcraft_svg::parse(br#""#).unwrap(); + let (rotated, offset) = layer_doc_with_sizes(&doc, 0, true); + assert_eq!(offset, [30.0, 10.0]); + assert_eq!(rotated.pixel_size(), (10, 30)); + let (stroked, offset) = layer_doc_with_sizes(&doc, 1, true); + assert_eq!(offset, [8.0, 18.0]); + assert_eq!(stroked.pixel_size(), (24, 4)); +} + +#[test] +fn retained_layers_preserve_artwork_beyond_the_bounds_depth_limit() { + let mut doc = effectcraft_svg::parse(br#""#).unwrap(); + for _ in 0..300 { + let mut group = Group::new("nested"); + group.children = std::mem::take(&mut doc.root.children); + doc.root.children.push(Node::Group(group)); + } + let (cropped, offset) = layer_doc_with_sizes(&doc, 0, true); + assert_eq!(cropped.pixel_size(), (120, 80), "unknown bounds keep the full page"); + assert_eq!(offset, [0.0, 0.0]); +} diff --git a/crates/svg/src/bounds.rs b/crates/svg/src/bounds.rs new file mode 100644 index 00000000..f0613aff --- /dev/null +++ b/crates/svg/src/bounds.rs @@ -0,0 +1,64 @@ +use kurbo::{Affine, BezPath, Rect, Shape as _}; + +use crate::{Doc, Group, Node}; + +impl Doc { + /// Geometric artwork bounds in document pixels, including strokes and clipping. + /// Opacity and soft masks do not change the artwork's dimensions. + pub fn artwork_bounds(&self) -> Option { + let page = Rect::new(0.0, 0.0, self.width, self.height); + group_bounds(&self.root, Affine::IDENTITY, 0, page) + } +} + +fn path_bounds(path: BezPath) -> Option { + if path.elements().is_empty() || !path.is_finite() { + return None; + } + let r = path.bounding_box(); + (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) +} + +fn group_bounds(group: &Group, transform: Affine, depth: usize, page: Rect) -> Option { + if depth > 256 { + return Some(page); + } + let transform = transform * group.transform; + if !transform.as_coeffs().iter().all(|v| v.is_finite()) { + return None; + } + let mut bounds: Option = None; + for node in &group.children { + let rect = match node { + Node::Group(g) => group_bounds(g, transform, depth + 1, page), + Node::Image(i) => { + let rect = Rect::new(0.0, 0.0, f64::from(i.width), f64::from(i.height)); + path_bounds(transform * i.transform * rect.to_path(0.01)) + } + Node::Shape(s) => { + let m = transform * s.transform; + let path = s.geom.to_path(); + let mut rect = s.fill.as_ref().and_then(|_| path_bounds(m * &path)); + if let Some(stroke) = &s.stroke { + let scale = m.determinant().abs().sqrt(); + if scale.is_finite() + && path.is_finite() + && let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &stroke.style(), scale) + && let Some(r) = path_bounds(m * outline) + { + rect = Some(rect.map_or(r, |b| b.union(r))); + } + } + rect + } + }; + if let Some(r) = rect { + bounds = Some(bounds.map_or(r, |b| b.union(r))); + } + } + let mut bounds = bounds?; + for (clip, _) in &group.clip { + bounds = bounds.intersect(path_bounds(transform * clip)?); + } + (bounds.width() > 0.0 && bounds.height() > 0.0).then_some(bounds) +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 7310814b..bcffb800 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod bounds; mod color; mod pathdata; mod raster; @@ -108,6 +109,28 @@ pub struct Stroke { pub dash: Option<(Vec, f64)>, } +impl Stroke { + fn style(&self) -> effectcraft_path::StrokeStyle { + effectcraft_path::StrokeStyle { + width: self.width, + cap: match self.cap { + Cap::Butt => effectcraft_path::Cap::Butt, + Cap::Round => effectcraft_path::Cap::Round, + Cap::Square => effectcraft_path::Cap::Square, + }, + join: match self.join { + Join::Miter => effectcraft_path::Join::Miter, + Join::Round => effectcraft_path::Join::Round, + Join::Bevel => effectcraft_path::Join::Bevel, + }, + miter: self.miter, + dash: self.dash.clone(), + taper: None, + wave: None, + } + } +} + /// Shape geometry in its user space (basic shapes are kept so they can become parametric shape /// layer items). #[derive(Clone, Debug, PartialEq)] diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index b1b9d93b..af59e33d 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -405,23 +405,7 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) { paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox); } if let Some(st) = &s.stroke { - let style = effectcraft_path::StrokeStyle { - width: st.width, - cap: match st.cap { - crate::Cap::Butt => effectcraft_path::Cap::Butt, - crate::Cap::Round => effectcraft_path::Cap::Round, - crate::Cap::Square => effectcraft_path::Cap::Square, - }, - join: match st.join { - crate::Join::Miter => effectcraft_path::Join::Miter, - crate::Join::Round => effectcraft_path::Join::Round, - crate::Join::Bevel => effectcraft_path::Join::Bevel, - }, - miter: st.miter, - dash: st.dash.clone(), - taper: None, - wave: None, - }; + let style = st.style(); let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h); paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox); } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 275e54b7..185c1033 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -228,7 +228,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { "file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => ( "Import PDF / Illustrator File".into(), vec![ - Field::choice("importAs", "Import Kind", &[("Footage", json!("footage")), ("Composition", json!("composition"))], 0), + Field::choice( + "importAs", + "Import Kind", + &[ + ("Footage", json!("footage")), + ("Composition", json!("composition")), + ("Composition - Retain Layer Sizes", json!("compositionLayerSizes")), + ], + 0, + ), Field::num("page", "Page", 1.0), ], ), diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 77a3995d..9b66f745 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -584,3 +584,28 @@ fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() { assert!(h.state().auto.find("form.field.missingFrames").is_none()); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn illustrator_import_offers_and_applies_retain_layer_sizes() { + let path = std::env::temp_dir().join(format!("effectcraft-retain-ui-{}.ai", std::process::id())); + std::fs::write(&path, b"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Contents 4 0 R >> endobj\n4 0 obj << >> stream\n1 0 0 rg 20 15 30 20 re f\nendstream endobj\n%%EOF").unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(effectcraft_host::session())); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({"paths": [path]})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Import PDF / Illustrator File").is_some()); + click_id(&mut h, "form.field.importAs"); + h.get_by_label("Composition - Retain Layer Sizes").click(); + h.run_steps(2); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.active_comp().is_some()); + let s = &h.state().session; + let comp = s.active_comp().unwrap(); + assert_eq!((comp.width, comp.height), (120, 80)); + assert_eq!(comp.layers.len(), 1); + let effectcraft_engine::project::LayerSource::Footage { item } = comp.layers[0].source else { panic!("footage") }; + let effectcraft_engine::project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height), (30, 20)); + assert!(f.layer.as_ref().unwrap().layer_size); +} From 26ed1a35bfebb9286817cf5a1e92a49e774e69f8 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 15 Oct 2026 01:09:25 -0700 Subject: [PATCH 094/185] fix(pdf): prevent overflow in EPS integer arithmetic Use checked division and remainder for EPS integer operators so i64::MIN with a divisor truncating to -1 cannot panic. Preserve the existing zero-divisor fallback and normal truncation behavior. Add public EPS parse regressions covering extreme, fractional and ordinary operands. PR #1223 --- crates/pdf/src/ps.rs | 18 ++++-------------- crates/pdf/src/tests_import.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 65e352cb..4401ace9 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -700,20 +700,10 @@ impl Interp { a / b } } - "idiv" => { - if b as i64 == 0 { - 0.0 - } else { - ((a as i64) / (b as i64)) as f64 - } - } - "mod" => { - if b as i64 == 0 { - 0.0 - } else { - ((a as i64) % (b as i64)) as f64 - } - } + // `checked_*` give 0 both for a zero divisor and for the one overflowing + // case, `i64::MIN / -1` (`/` and `%` panic on it in every build). + "idiv" => (a as i64).checked_div(b as i64).unwrap_or(0) as f64, + "mod" => (a as i64).checked_rem(b as i64).unwrap_or(0) as f64, "atan" => a.atan2(b).to_degrees().rem_euclid(360.0), "exp" => a.powf(b), "max" => a.max(b), diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 532e32cb..add447e3 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -376,3 +376,25 @@ fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() { let _ = parse(src.as_bytes()); } } + +#[test] +fn postscript_idiv_and_mod_of_i64_min_by_minus_one_give_zero() { + // `i64::MIN / -1` and `i64::MIN % -1` overflow, and `/` and `%` panic on them in debug and + // release builds. `as i64` turns any dividend at or below -2^63 into `i64::MIN` and turns + // a divisor of -1.5 into -1. + for (prog, want) in [ + ("-9223372036854775808 -1 idiv", 0), + ("-9223372036854775808 -1 mod", 0), + ("-99999999999999999999 -1.5 idiv", 0), + ("2 63 exp neg -1 mod", 0), + // The result is 0 for a divisor that truncates to 0. Other quotients are truncated + // toward zero, and a remainder has the sign of the dividend. + ("7 0 idiv", 0), + ("7 0.5 mod", 0), + ("-7 2 idiv", -3), + ("-7 2 mod", -1), + ] { + let doc = eps(format!("{prog} {want} eq {{ 0 0 10 10 rectfill }} if\n").as_bytes()); + assert_eq!(shape_names(&doc).len(), 1, "{prog}"); + } +} From 0311bdc34d0dc10c2d18ff7875b4f52d483e5e27 Mon Sep 17 00:00:00 2001 From: Moein Saharkhiz <142007707+onlymoein@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:13:41 -0400 Subject: [PATCH 095/185] fix(automation): avoid replaying edits after lost replies Retry only explicitly read-only control queries after connection failures. Report ambiguous edit outcomes without resending mutations, reconnect on the next explicit call, and reject request-ID exhaustion without overflow. Add loopback regressions for lost and malformed replies, read-only retries, playback queries versus edits, and unknown methods. Fixes #1247 --- crates/automation/src/bridge.rs | 17 +++-- crates/automation/src/tests.rs | 122 ++++++++++++++++++++++++++++++++ docs/agents.md | 5 ++ 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs index eab1c01d..77d69dd0 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -1,5 +1,5 @@ //! Blocking client for the desktop app's JSON-lines control channel (`docs/control-protocol.md`). -//! One persistent loopback connection, reconnected once if the app went away. +//! One persistent loopback connection. Only explicitly read-only queries can be resent. use std::io::{BufRead, BufReader, Write}; use std::net::{TcpStream, ToSocketAddrs}; @@ -50,16 +50,20 @@ impl BridgeClient { /// Call a control method; returns the reply's `result`, or the app's `error` as [`Error::App`]. pub fn call(&mut self, method: &str, params: Value) -> Result { + let retry_read = + matches!(method, "engine.commands" | "ui.menu.list" | "ui.inspect" | "ui.elements" | "ui.viewer.locate" | "ui.viewer.hit" | "ui.timeline.locate") + || (method == "ui.playback" && params["action"] == "status"); + let unknown = |error| Error::Bridge(format!("`{method}` request outcome is unknown: {error}; inspect the app before retrying")); let mut last = Error::Bridge("not attempted".into()); for attempt in 0..2 { if self.conn.is_none() { self.conn = Some(self.connect()?); } let id = self.next_id; - self.next_id += 1; + self.next_id = self.next_id.checked_add(1).ok_or_else(|| Error::Bridge("control request id space exhausted".into()))?; let Some((r, w)) = self.conn.as_mut() else { continue }; let line = format!("{}\n", json!({"id": id, "method": method, "params": params})); - // `Ok(Err(_))`: the connection is dead before a reply (safe to resend once); + // `Ok(Err(_))`: transmission/reply failed (the app may have applied the request); // `Err(_)`: a timeout or bad reply (never resent: the app may have run the request). let res = (|| -> Result> { if let Err(e) = w.write_all(line.as_bytes()) { @@ -80,8 +84,11 @@ impl BridgeClient { Ok(Ok(v)) if v.get("ok").and_then(Value::as_bool) == Some(true) => return Ok(v.get("result").cloned().unwrap_or(Value::Null)), Ok(Ok(v)) => return Err(Error::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())), Ok(Err(e)) => { - // Stale connection (app restarted): reconnect and resend once. self.conn = None; + if !retry_read { + return Err(unknown(e)); + } + // A read-only query can be repeated after a stale connection or lost reply. last = e; if attempt == 1 { break; @@ -90,7 +97,7 @@ impl BridgeClient { Err(e) => { // The stream may still deliver the late reply: start fresh next call. self.conn = None; - return Err(e); + return Err(if retry_read { e } else { unknown(e) }); } } } diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6bb6fbc5..6e622b37 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -511,6 +511,128 @@ fn bridge_rejects_non_loopback() { assert!(Backend::bridge("localhost:9877").is_ok()); } +/// The app applies the first request but loses its reply. The next connection can be either +/// an automatic retry or an explicit follow-up; the returned counts distinguish them. +fn lost_reply_server(first_reply: Option<&'static str>) -> (String, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let worker = std::thread::spawn(move || { + let mut session = Session::default(); + let mut mutations = 0; + for attempt in 0..2 { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut output = stream.try_clone().unwrap(); + let mut input = String::new(); + BufReader::new(stream).read_line(&mut input).unwrap(); + let request: Value = serde_json::from_str(&input).unwrap(); + let result = if request["method"] == "engine.execute" { + mutations += 1; + session.execute(request["params"]["command"].as_str().unwrap(), request["params"]["params"].clone()).unwrap() + } else if request["method"] == "ui.inspect" || (request["method"] == "ui.playback" && request["params"]["action"] == "status") { + json!({"mutations": mutations, "requests": attempt + 1, "comps": session.project.comps().count()}) + } else { + mutations += 1; + json!({"mutations": mutations}) + }; + if attempt == 1 { + writeln!(output, "{}", json!({"id": request["id"], "ok": true, "result": result})).unwrap(); + } else if let Some(reply) = first_reply { + writeln!(output, "{reply}").unwrap(); + } + } + }); + (addr, worker) +} + +#[test] +fn bridge_does_not_replay_a_mutation_when_its_reply_is_lost() { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let error = backend.exec("comp.new", json!({"width": 64, "height": 48, "duration": 1})).unwrap_err(); + assert!(error.to_string().contains("outcome is unknown"), "{error}"); + let state = backend.control("ui.inspect", json!({})).unwrap(); + assert_eq!(state["mutations"], 1, "the first command was applied exactly once"); + assert_eq!(state["comps"], 1); + worker.join().unwrap(); +} + +#[test] +fn bridge_retries_read_only_queries_after_a_lost_reply() { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let state = backend.control("ui.inspect", json!({})).unwrap(); + assert_eq!(state["requests"], 2); + assert_eq!(state["mutations"], 0); + worker.join().unwrap(); +} + +#[test] +fn bridge_reports_unknown_outcome_after_a_malformed_mutation_reply() { + let (addr, worker) = lost_reply_server(Some("{invalid reply")); + let mut backend = Backend::bridge(&addr).unwrap(); + let error = backend.exec("comp.new", json!({"width": 64, "height": 48, "duration": 1})).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("outcome is unknown") && message.contains("bad reply"), "{message}"); + assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["comps"], 1); + worker.join().unwrap(); +} + +#[test] +fn bridge_does_not_assume_an_unknown_method_is_read_only() { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let error = backend.control("future.customOperation", json!({})).unwrap_err(); + assert!(error.to_string().contains("outcome is unknown"), "{error}"); + assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["mutations"], 1); + worker.join().unwrap(); +} + +#[test] +fn bridge_playback_edits_and_default_toggle_are_not_replayed() { + for params in [json!({}), json!({"action": "toggle"}), json!({"action": "play"}), json!({"action": "stop"})] { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let error = backend.control("ui.playback", params).unwrap_err(); + assert!(error.to_string().contains("outcome is unknown"), "{error}"); + assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["mutations"], 1); + worker.join().unwrap(); + } +} + +#[test] +fn bridge_playback_status_can_be_retried() { + let (addr, worker) = lost_reply_server(None); + let mut backend = Backend::bridge(&addr).unwrap(); + let state = backend.control("ui.playback", json!({"action": "status"})).unwrap(); + assert_eq!(state["requests"], 2); + assert_eq!(state["mutations"], 0); + worker.join().unwrap(); +} + +#[test] +fn bridge_connect_failure_can_be_retried_when_the_app_starts() { + let reserved = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = reserved.local_addr().unwrap(); + drop(reserved); + let mut backend = Backend::bridge(&addr.to_string()).unwrap(); + let error = backend.control("ui.inspect", json!({})).unwrap_err(); + assert!(error.to_string().contains("cannot connect"), "{error}"); + assert!(!error.to_string().contains("outcome is unknown"), "nothing was sent"); + let listener = TcpListener::bind(addr).unwrap(); + let worker = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut output = stream.try_clone().unwrap(); + let mut input = String::new(); + BufReader::new(stream).read_line(&mut input).unwrap(); + let request: Value = serde_json::from_str(&input).unwrap(); + writeln!(output, "{}", json!({"id": request["id"], "ok": true, "result": {"ready": true}})).unwrap(); + }); + assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["ready"], true); + worker.join().unwrap(); +} + #[test] fn script_ui_and_history_tools() { let mut sess = Session::default(); diff --git a/docs/agents.md b/docs/agents.md index 1bec71d5..ed0073d4 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -52,6 +52,11 @@ Desktop, Cursor and the like) take the same `command` and `args`. - **Bridge** (`["mcp", "--bridge", "9877"]`): drives a running `effectcraft --control 9877`, so you see every change live. Bridge mode adds `screenshot` and the `ui_*` tools. +After a broken connection, the bridge retries explicitly read-only control queries once. +Engine commands, UI edits and unknown methods whose transmission or reply fails report an +unknown outcome: the app may already have applied them. Inspect the app before explicitly +retrying. A later call reconnects; timeouts and malformed replies are never automatically resent. + ### From an installed release The release packages ship `effectcraft-cli` alongside the desktop app, so no build is needed: From 77a8d6ae73e599106c2da39e477f4d2c8db1ae83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Thu, 15 Oct 2026 10:19:42 +0200 Subject: [PATCH 096/185] gpu: upload cache drops textures of freed buffers on every insert Entries whose layer buffer was freed were pruned only once the cache went over its budget (1.5 GiB), so an animated layer, which makes a new buffer every frame, left a dead 4K texture per frame allocated until the cache filled up. --- crates/gpu/src/context.rs | 20 +++++++++++++------- crates/gpu/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 51221abc..0c5763a7 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -919,6 +919,12 @@ impl GpuContext { /// Upload a (layer cache) buffer, reusing the texture while the same `Arc` is alive: static /// layers upload once, not every frame. + /// Entries in the layer upload cache. + #[cfg(test)] + pub(crate) fn upload_entries(&self) -> usize { + self.uploads.lock().map(|u| u.map.len()).unwrap_or_default() + } + pub(crate) fn upload_buf(&self, buf: &Arc) -> Option { let key = Arc::as_ptr(buf) as usize; if let Ok(mut u) = self.uploads.lock() { @@ -939,14 +945,14 @@ impl GpuContext { u.bytes -= old.bytes; } u.bytes += bytes; - // Drop textures of freed buffers, then the least recently used over budget. + // Drop textures of freed buffers at once (a layer whose buffer changes every frame + // makes a new one each time, and nothing can hit the old one again), then the least + // recently used over budget. let budget = self.upload_budget(); - if u.bytes > budget { - let dead: Vec = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect(); - for k in dead { - if let Some(e) = u.map.remove(&k) { - u.bytes -= e.bytes; - } + let dead: Vec = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect(); + for k in dead { + if let Some(e) = u.map.remove(&k) { + u.bytes -= e.bytes; } } if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) { diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 696402d6..532e313f 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -1044,3 +1044,19 @@ fn static_effect_layers_draw_from_the_cache() { assert!(frames[1] == frames[2]); check("static effect layer vs CPU", compare_at(&s, opts(), t(2)), 0.0); } + +/// The layer upload cache drops the textures of freed buffers when it inserts, not only once +/// it is over budget (an animated layer makes a new buffer every frame). +#[test] +fn upload_cache_drops_textures_of_freed_buffers() { + let Some(g) = gpu() else { return }; + let img = effectcraft_raster::Image::new(256, 256); + let before = g.ctx.upload_entries(); + for _ in 0..6 { + let buf = std::sync::Arc::new(effectcraft_effects::Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }); + let up = g.ctx.upload_buf(&buf).expect("upload"); + drop((up, buf)); + } + let kept = g.ctx.upload_entries(); + assert!(kept <= before + 1, "the upload cache kept {kept} textures of freed buffers (had {before})"); +} From 8d807e6e79d8b974038c1b9ffca53f4f9b773463 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 14:58:22 +0530 Subject: [PATCH 097/185] Contributors: add arnav-hiwarkar Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 705feb25..3e6a78c3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -78,3 +78,7 @@ sync_github_name = true [people.moecr7] real_name = "Moein Saharkhiz" display_name = "onlymoein" + +[people.arnav-hiwarkar] +display_name = "arnav-hiwarkar" +sync_github_name = false From 9635321f1a11a16ec094dc3685afc938ff03953e Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:42:50 +0300 Subject: [PATCH 098/185] fix(script): per-key methods act on keys outside the active comp (#1531) `exec_on_keys` put the named keys in the key selection, then `exec` made the keys' comp active. Since comps keep their own selections (#1037), activating the comp stored the injected keys with the comp that had been active and brought back the target comp's own key selection, usually empty, so `keys.interpolation` and `keys.velocity` had nothing to act on: setInterpolationTypeAtKey and setTemporalEaseAtKey were silently ignored in any comp other than the active one. The keys' comp is now made active before the keys are selected, and the comp that was active comes back afterwards, as `exec` does for its own target (now `leave_comp`). --- crates/script/src/runtime.rs | 22 ++++++++++++++++------ crates/script/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index b94bd80f..5835f6b6 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -157,11 +157,16 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { s.state.selected_layers.clear(); } let r = s.execute_for_script(id, params); - if let Some(c) = target - && prev != Some(c) - && s.state.active_comp == Some(c) - && id != "comp.open" - { + if let Some(c) = target { + leave_comp(s, id, c, prev); + } + r.map_err(|e| e.to_string()) +} + +/// After a script's command on comp `c`, make `prev` (the comp active before it) active again, +/// unless the command opened `c` on purpose; with no comp to go back to, `c` stays open. +fn leave_comp(s: &mut Session, id: &str, c: effectcraft_engine::project::ItemId, prev: Option) { + if prev != Some(c) && s.state.active_comp == Some(c) && id != "comp.open" { match prev { Some(p) if s.project.comp(p).is_some() => s.activate_comp(p), _ => { @@ -171,7 +176,6 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { } } } - r.map_err(|e| e.to_string()) } /// Run a keyframe command that acts on the selected keys (`keys.velocity`, `keys.interpolation`, @@ -195,11 +199,17 @@ fn exec_on_keys(s: &mut Session, id: &str, params: J, targets: &J) -> Result 1.0), "{v}"); +} + /// #808: sourcePointToComp and compPointToSource follow the layer's transform. #[test] fn source_point_conversions_follow_the_layer_transform() { From a8b25043a9d7b48a0f8337afbceecd8b61c01678 Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:52:58 +0300 Subject: [PATCH 099/185] fix(viewer): Shift-click with the Pen draws a straight segment (#1544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pen_press` placed each new vertex where it was clicked (after snapping) whatever the modifiers, so Shift did nothing and a vertical, horizontal or 45° segment had to be lined up by eye. With Shift held, the new vertex now moves onto the nearest line at a multiple of 45° through the vertex before it, as far along that line as the click lies (`constrain_45`). Masks and shape paths both go through this path. --- crates/ui-egui/src/panels/viewer.rs | 32 +++++++++++++++++++++++++++ crates/ui-egui/tests/ui_viewer.rs | 34 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..98d86568 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2221,6 +2221,12 @@ fn pen_press( }); return; } + // Shift keeps the new segment at a multiple of 45° from the vertex before it (#1544). + let last = p.sp.vertices.last().map(|v| p.m.apply(gv2(v[0], v[1]))); + let c = match last { + Some(q) if mods.shift => constrain_45([q.x, q.y], c), + _ => c, + }; let lp = inv.apply(gv2(c[0], c[1])); let lp = [lp.x, lp.y]; if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": uid, "point": lp})) { @@ -2282,6 +2288,20 @@ fn pen_press( }); } +/// `to` moved onto the nearest line through `from` at a multiple of 45° (horizontal, vertical or +/// diagonal), as far along it as `to` lies: the Pen's Shift-click. +fn constrain_45(from: [f64; 2], to: [f64; 2]) -> [f64; 2] { + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { + return to; + } + let step = std::f64::consts::FRAC_PI_4; + let a = (dy.atan2(dx) / step).round() * step; + let (ux, uy) = (a.cos(), a.sin()); + let along = dx * ux + dy * uy; + [from[0] + ux * along, from[1] + uy * along] +} + /// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. /// Solo scope matches the renderer: only active AV layers activate it, even when hidden or locked. pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { @@ -2684,6 +2704,18 @@ mod tests { use super::*; use effectcraft_engine::geom::{Mat4, vec3}; + /// #1544: Shift-clicking with the Pen keeps the new segment horizontal, vertical or diagonal. + #[test] + fn pen_shift_click_keeps_the_segment_at_45_degrees() { + let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9; + let from = [100.0, 100.0]; + assert!(near(constrain_45(from, [104.0, 180.0]), [100.0, 180.0]), "straight down"); + assert!(near(constrain_45(from, [30.0, 97.0]), [30.0, 100.0]), "left"); + assert!(near(constrain_45(from, [150.0, 46.0]), [152.0, 48.0]), "up and right, on the diagonal"); + assert!(near(constrain_45(from, from), from), "on the vertex"); + assert!(constrain_45(from, [f64::NAN, 3.0])[0].is_nan(), "a non-finite point stays as it is"); + } + /// The Rotation tool counts past a full turn in both directions, as in After Effects: the /// pointer going round the anchor 1.5 turns clockwise gives 540°, then back 2 turns gives /// −180°, with no jump where atan2 wraps from 180° to −180°. diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1..4dd3c2a3 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -761,6 +761,40 @@ fn mask_path(h: &Harness<'_, EffectcraftApp>, name: &str) -> effectcraft_engine: l.masks().unwrap().groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().clone() } +/// #1544: a Shift-click with the Pen puts the next point straight below, beside or diagonal to the +/// one before it; without Shift it goes where it was clicked. +#[test] +fn pen_shift_click_draws_a_straight_segment() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let first = screen(&h, [100.0, 60.0]); + click(&mut h, first); + let shift_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + let shift = egui::Modifiers::SHIFT; + h.input_mut().events.push(Event::ModifiersChanged(shift)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: shift }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: shift }); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); + }; + let down = screen(&h, [112.0, 250.0]); + shift_click(&mut h, down); + let free = screen(&h, [300.0, 290.0]); + click(&mut h, free); + let drawn = mask_path(&h, "Plate"); + let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0; + assert_eq!(drawn.vertices.len(), 3, "{drawn:?}"); + assert!(near(drawn.vertices[1], [100.0, 250.0]), "straight down: {drawn:?}"); + assert!(near(drawn.vertices[2], [300.0, 290.0]), "no Shift, no constraint: {drawn:?}"); +} + /// Drawing a mask with the Pen, moving a point and undoing take one action back per undo; a /// double-click on a point selects all of them in a free-transform box, and dragging inside it /// (or, once the box is gone, dragging a point) moves the whole mask (#290). From 400379fd20594ca0b8e64a29b71ada8abf1af55d Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:05:32 +0300 Subject: [PATCH 100/185] pdf: a soft mask at an unsupported bit depth is ignored, not fatal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mask_image read /BitsPerComponent from the attacker-controlled /SMask dict with no validation and fed it straight into 1u32 << bpc — the main image path rejects anything but 1|2|4|8|16 before its own identical shift, the mask path did not. /BitsPerComponent 32 panics in debug and test builds and wraps the shift in release, mistranslating every sample into alpha. Mirror decode()'s depth check: an unsupported mask depth means no usable mask, so the image draws with the mask treated as absent. Regression test: smask_with_absurd_bpc_is_ignored_not_fatal — panics at crates/pdf/src/image.rs:184 on main, passes with the check. --- crates/pdf/src/image.rs | 5 +++++ crates/pdf/src/tests_import.rs | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs index 5e6c49a5..1b39f062 100644 --- a/crates/pdf/src/image.rs +++ b/crates/pdf/src/image.rs @@ -181,6 +181,11 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h)); } let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 }; + // Like `decode()`: only 1/2/4/8/16-bit depths are supported. Anything else (notably + // `bpc >= 32`, where `1u32 << bpc` below would overflow) means "no usable mask". + if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { + return None; + } let max = ((1u32 << bpc) - 1).max(1) as f64; let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2); let v = unpack(&data, w, h, 1, bpc); diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index add447e3..064b5614 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -263,6 +263,31 @@ fn hostile_structures_return_errors_or_draw_nothing() { assert!(!doc.skipped.is_empty()); } +#[test] +fn smask_with_absurd_bpc_is_ignored_not_fatal() { + // `/BitsPerComponent 32` in a soft mask: `1u32 << bpc` must neither panic (debug) + // nor mistranslate samples into alpha (release); the mask is treated as absent. + let bytes = page_pdf( + "q 2 0 0 2 0 0 cm /Im Do Q", + "/XObject << /Im 10 0 R >>", + vec![ + ( + 10, + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 11 0 R >>".into(), + Some(vec![0x80; 4]), + ), + ( + 11, + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 32 >>".into(), + Some(vec![0xFF; 16]), + ), + ], + ); + let doc = parse(&bytes).unwrap(); + let img = render(&doc); + assert!(coverage(&img, 0, 0, 200, 100) > 0.0, "image still draws, opaque"); +} + #[test] fn truncated_and_corrupt_eps_never_panic() { let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n\ From 5e18a7fd922510fc7c3108a827a35fbe5b1abd09 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:10:10 +0300 Subject: [PATCH 101/185] pdf: an inline image with absurd dimensions falls back to the EI scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inline_len computed (w * n * bpc).div_ceil(8) * h with Width/Height taken straight from the content stream and no size validation — unlike decode() and mask_image, which bound their geometry first. /W 5000000000000000000 overflows the multiply in debug and test builds; in release it wraps to a small length and the caller slices the wrong bytes, misparsing the image and shifting the EI resync. Chain the arithmetic through checked_mul and return None, which the caller already routes to the EI-scan fallback. Regression test: inline_image_with_absurd_dimensions_falls_back_to_ei_scan — panics at crates/pdf/src/image.rs:85 on main, passes with the chain. --- crates/pdf/src/image.rs | 5 ++++- crates/pdf/src/tests_import.rs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs index 5e6c49a5..69ff911e 100644 --- a/crates/pdf/src/image.rs +++ b/crates/pdf/src/image.rs @@ -82,7 +82,10 @@ pub(crate) fn inline_len(file: &File, d: &Dict, res: Option<&Dict>) -> Option Date: Thu, 15 Oct 2026 16:18:10 +0300 Subject: [PATCH 102/185] project: a hostile ROI cannot overflow the crop math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roi quad is stored raw from the control channel (crop.useRoi passes serde_json's [f64;4] straight through, unlike the sibling edge fields, which are clamped to plus/minus 20 000) and nothing repairs it on load. Crop::edges converted it with round() as i32 and subtracted raw: an x of -1e12 saturates to i32::MIN and w as i32 - l overflows in debug builds, wrapping to a wrong-but-bounded crop in release. Clamp each converted edge to the same plus/minus 20 000 the sibling fields use and subtract with saturating_sub in edges() and frame_size(). Regression test: hostile_roi_cannot_overflow_crop_math — panics at crates/project/src/render_queue.rs:774 on main; with the fix the hostile quad degrades to a 1x1 frame and the sane-ROI behavior is unchanged. --- crates/project/src/render_queue.rs | 32 ++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 5c13dffc..2d3ab741 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -769,10 +769,14 @@ impl Crop { if self.use_roi && let Some([x, y, rw, rh]) = self.roi { - let l = (x * scale).round() as i32; - let t = (y * scale).round() as i32; - let r = w as i32 - ((x + rw) * scale).round() as i32; - let b = h as i32 - ((y + rh) * scale).round() as i32; + // The quad arrives unvalidated (control channel or a hand-edited project): + // clamp like the sibling edge fields (plus/minus 20 000) and subtract + // without overflow. + let conv = |v: f64| v.round().clamp(-20_000.0, 20_000.0) as i32; + let l = conv(x * scale); + let t = conv(y * scale); + let r = (w as i32).saturating_sub(conv((x + rw) * scale)); + let b = (h as i32).saturating_sub(conv((y + rh) * scale)); return (t, l, b, r); } (self.top, self.left, self.bottom, self.right) @@ -1094,8 +1098,8 @@ impl OutputModule { /// scale `scale`: after Crop and Resize (before codec rounding). pub fn frame_size(&self, w: u32, h: u32, scale: f64) -> (u32, u32) { let (t, l, b, r) = self.crop.edges(w, h, scale); - let cw = (w as i32 - l - r).max(1) as u32; - let ch = (h as i32 - t - b).max(1) as u32; + let cw = (w as i32).saturating_sub(l).saturating_sub(r).max(1) as u32; + let ch = (h as i32).saturating_sub(t).saturating_sub(b).max(1) as u32; if !self.resize.enabled { return (cw, ch); } @@ -1643,6 +1647,22 @@ mod tests { assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0)); } + #[test] + fn hostile_roi_cannot_overflow_crop_math() { + // `-1e12` saturates to `i32::MIN` on conversion: `w as i32 - l` must saturate, + // not overflow, and still yield a usable (degenerate) frame size. + let c = Comp::new(1920, 1080, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + let rs = RenderSettings::default(); + let mut m = OutputModule::for_format(OutputFormat::PngSequence); + m.crop = Crop { enabled: true, use_roi: true, roi: Some([-1e12, -1e12, 200.0, 200.0]), ..Default::default() }; + let (w, h) = m.output_size(&c, &rs); + assert!((w, h) == (1, 1), "degenerate but valid: {w}x{h}"); + // Sane ROIs are unaffected (existing behavior). + m.crop = Crop { enabled: true, use_roi: true, roi: Some([100.0, 50.0, 800.0, 400.0]), ..Default::default() }; + let half = RenderSettings { resolution: 0.5, ..Default::default() }; + assert_eq!(m.output_size(&c, &half), (400, 200)); + } + #[test] fn uncompressed_quicktime_format() { let f = OutputFormat::from_name("uncompressed").unwrap(); From a3c3b8530c927b198749e5468c669bc873d11f05 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 15:30:59 +0200 Subject: [PATCH 103/185] Text: a variable font's named instances are styles of its family (#1563) The font scan read only whether a face has an fvar table, so a variable family offered one style (its default instance), fontStyle = "Black" picked that face with synthetic bold, and an instance's PostScript name (Figtree-Black, what After Effects scripts use) found no face at all. The scan now reads the named instances (style, axis values, PostScript name, or the one Adobe Technical Note #5902 derives). They are styles of the family in text.fonts and the font menus, a style, PostScript name or full name selects the instance, and layout uses its axis values as the base variations; axes set on the text still win. --- crates/engine/src/tests_text_edit.rs | 60 +++++++ crates/text/src/fonts.rs | 139 ++++++++++++++-- crates/text/src/layout.rs | 44 ++++++ crates/text/src/sfnt.rs | 193 ++++++++++++++++++++++- crates/text/src/variable.rs | 10 +- crates/ui-egui/src/panels/text_panels.rs | 7 +- 6 files changed, 435 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 3e41688d..243a9c67 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -317,6 +317,66 @@ fn fonts_filter_variable_families() { assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable)); } +/// A variable font of `name` and `fvar` only: one `wght` axis 100..900 (default 300, style +/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name +/// `-
Editing The Great Wave off Kanagawa with type layers and adjustment layersDemo composition with text layers, timeline and properties