From b63efeb0d3719f7cf21f9d641a5eb011f989c728 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 13 Oct 2026 15:12:32 +0530 Subject: [PATCH 01/56] 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 da06216475cf3e7ea5aaedf40169220f6e7aa824 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 13 Oct 2026 16:48:19 +0530 Subject: [PATCH 02/56] 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 03/56] 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 04/56] 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 05/56] #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 1cc315708e2d50675efdd787399547c7fee510a9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 07:13:36 -0700 Subject: [PATCH 06/56] 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 f12d75e37aa2f8f2032b3bf9eafa9434e55b2b03 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:15:32 +1100 Subject: [PATCH 07/56] 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 08/56] 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 09/56] 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 10/56] 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 11/56] 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 5569aad8b5f22715ac8052708c5e05acbe48b53d Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Wed, 14 Oct 2026 01:13:22 +0900 Subject: [PATCH 12/56] 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 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 13/56] #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 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 14/56] 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 fdc515f5bea165e6cf304890443f0e861026fb56 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 21:58:52 +0200 Subject: [PATCH 15/56] #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 8fea81445c24e528b3444cea2630a45b1d91edba Mon Sep 17 00:00:00 2001 From: Daniel van Lith Date: Wed, 14 Oct 2026 10:36:50 +1300 Subject: [PATCH 16/56] 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 17/56] 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 be3261419b66a49359577d3173abb2c7ee6a7768 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Wed, 14 Oct 2026 00:46:05 +0200 Subject: [PATCH 18/56] =?UTF-8?q?Viewer:=20Composition=20Settings=E2=80=A6?= =?UTF-8?q?=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 19/56] 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 7df16a5e20f7c24a3c702d6c963651fb7b09b85c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 02:56:17 +0200 Subject: [PATCH 20/56] 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 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 21/56] 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 22/56] 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 23/56] 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 68d928aff325c9af3682c840b4ef7e14a015b97a Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 14 Oct 2026 13:42:59 +0530 Subject: [PATCH 24/56] 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 78b4fe4170c998cf21dce86f98fcdb5c7376c91d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:44:59 +0200 Subject: [PATCH 25/56] 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 449e9edd5908f332e8775df6899902e0cc1c9e0d Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 14 Oct 2026 17:21:58 +0500 Subject: [PATCH 26/56] 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 27/56] 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 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 28/56] 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 a213b7f23a19a5cfe7226905cd08467afca72ac8 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 14 Oct 2026 21:21:39 +0500 Subject: [PATCH 29/56] 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 30/56] 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 75b75f8463f3f55e763012bc87846d6de2804616 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 01:25:46 +0530 Subject: [PATCH 31/56] 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 32/56] 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 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 33/56] 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 @@ - Editing The Great Wave off Kanagawa with type layers and adjustment layers - https://raw.githubusercontent.com/storytold/effectcraft/main/docs/images/effectcraft-demo.jpg + Demo composition with text layers, timeline and properties + https://raw.githubusercontent.com/storytold/effectcraft/05955c08b4fd025f1a09882df72e6dbff1a8148a/docs/images/effectcraft-hero.png 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 34/56] 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 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 35/56] 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 c280506ba0261cc1c34656857907d18410f4cdcd Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 14 Oct 2026 20:51:09 -0600 Subject: [PATCH 36/56] 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 37/56] 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 2d216151c688fb74558128e8bd7943c8a0ba0ad1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 13:33:50 +0530 Subject: [PATCH 38/56] 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 39/56] 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 40/56] 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 41/56] 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 42/56] 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 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 43/56] 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 44/56] 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 c4c079d4f4ae5c344ac4b23d8dbb11625830fd5d Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 20:57:50 +0530 Subject: [PATCH 45/56] fix(engine): copy only the selected keyframes across properties (#1552) Clicking keyframes on Position, Rotation and Scale highlights those properties, and Copy then added every key of a highlighted property that had none selected, so deselected keys came back on paste. The keyframe copy branch also left a stale value clipboard that Paste preferred over the new keys. Copy now takes whole animations only when no key is selected or the Transform group is selected, and every copy branch clears the clipboards. Also collapses a nested `if` in the STL importer into a match guard, which Clippy 1.95 flags under -D warnings (no behaviour change). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/edit.rs | 13 ++-- crates/engine/src/tests_menu_cmds.rs | 4 +- crates/engine/src/tests_timeline.rs | 79 +++++++++++++++++++++++ crates/model/src/stl.rs | 6 +- crates/ui-egui/tests/ui_keyframes.rs | 96 ++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 06ba43b1..bbdc43fe 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -228,9 +228,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { } // Keyframes selected → copy keys (pasted at the CTI). if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { - s.state.link_clipboard = None; - s.state.effect_clipboard.clear(); - s.state.contents_clipboard.clear(); + clear_clipboards(s); return s.execute("keys.copy", json!({})); } // Effects selected (Effect Controls / timeline) → copy the effects. @@ -256,12 +254,14 @@ fn copy(s: &mut Session, p: &Value) -> Result { } /// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After -/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and -/// an unanimated one its value, for Paste onto the same properties of other layers (#493). +/// Effects: an animated property copies its keyframes and an unanimated one its value, for Paste +/// onto the same properties of other layers (#493). With keyframes selected only those are copied, +/// even across several highlighted properties (#1552); a selected Transform group copies them all. /// `None` when no property (or Transform group) is selected. fn copy_properties(s: &mut Session) -> Result> { let Some(comp) = s.active_comp() else { return Ok(None) }; let mut leaves = vec![]; + let mut copy_all_keys = s.state.selected_keys.is_empty(); for (lid, uid) in &s.state.selected_props { let Some(l) = comp.layer(*lid) else { continue }; if l.props.find(*uid).is_some() { @@ -269,6 +269,7 @@ fn copy_properties(s: &mut Session) -> Result> { } else if let Some(g) = l.props.find_group(*uid) && l.props.match_path_of(*uid).as_deref() == Some("transform") { + copy_all_keys = true; g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid))); } } @@ -286,7 +287,7 @@ fn copy_properties(s: &mut Session) -> Result> { { values.push((path, pr.value.clone())); } - } else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) { + } else if copy_all_keys && !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) { keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); } } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f..5203872a 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -433,8 +433,10 @@ fn copy_a_selected_property_pastes_onto_the_same_property() { s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap(); s.execute("time.set", json!({"time": 1.0})).unwrap(); s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap(); + assert_eq!(s.state.selected_keys.len(), 2); let transform = layer(&s, a).props.sub("transform").unwrap().uid; - s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap(); + s.execute("prop.select", json!({"layer": a, "prop": transform, "selectKeys": false})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); s.execute("layer.select", json!({"layers": [b]})).unwrap(); s.execute("time.set", json!({"time": 2.0})).unwrap(); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 71a9943c..39a4d40b 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -108,6 +108,85 @@ fn keyframe_copy_paste_keeps_relative_timing_and_crosses_properties() { assert!(e.is_err() || prop(&s, l2, "transform/position").keys.is_empty()); } +#[test] +fn copy_selected_keys_excludes_deselected_properties_and_stale_values() { + let (mut s, source) = setup(); + s.execute("prop.set", json!({"layer": source, "path": "transform/opacity", "value": 25})).unwrap(); + select_prop_keys(&mut s, source, "transform/opacity"); + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(s.state.value_clipboard.len(), 1); + animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]); + animate(&mut s, source, "transform/rotation", &[(0.0, json!(15)), (1.0, json!(90))]); + animate(&mut s, source, "transform/scale", &[(0.0, json!([50, 60, 100])), (1.0, json!([150, 160, 100]))]); + select_prop_keys(&mut s, source, "transform/scale"); + s.execute( + "keys.select", + json!({"keys": [{"layer": source, "path": "transform/position", "time": 0}, {"layer": source, "path": "transform/rotation", "time": 1}], "add": true, "selectProperties": true}), + ) + .unwrap(); + s.execute( + "keys.select", + json!({"keys": [{"layer": source, "path": "transform/scale", "time": 0}, {"layer": source, "path": "transform/scale", "time": 1}], "toggle": true, "selectProperties": true}), + ) + .unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + assert_eq!(s.state.selected_keys.len(), 2); + assert_eq!(s.state.selected_props.len(), 3); + assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!(2)); + assert!(s.state.value_clipboard.is_empty()); + let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("time.set", json!({"time": 2})).unwrap(); + s.execute("layer.select", json!({"layers": [target]})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + undo_redo_roundtrip( + &mut s, + |s| { + for (path, time, value) in [("transform/position", 2.0, KV::Vec3([10.0, 20.0, 0.0])), ("transform/rotation", 3.0, KV::Scalar(90.0))] { + let p = prop(s, target, path); + assert_eq!(p.keys.len(), 1); + let k = &p.keys[0]; + assert_eq!((k.time.seconds(), &k.value), (time, &value)); + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier)); + assert_eq!(k.in_ease[0], effectcraft_keyframe::Ease::EASY); + assert_eq!(k.out_ease[0], effectcraft_keyframe::Ease::EASY); + } + assert_eq!(s.state.selected_keys.len(), 2); + assert_eq!(prop(s, target, "transform/scale").value, KV::Vec3([100.0; 3])); + assert!(prop(s, target, "transform/scale").keys.is_empty()); + assert_eq!(prop(s, target, "transform/opacity").value, KV::Scalar(100.0)); + }, + |s| { + assert!(prop(s, target, "transform/position").keys.is_empty()); + assert!(prop(s, target, "transform/rotation").keys.is_empty()); + }, + ); +} + +#[test] +fn copy_mixed_animated_and_static_properties_keeps_the_static_value() { + let (mut s, source) = setup(); + animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]); + s.execute("prop.set", json!({"layer": source, "path": "transform/rotation", "value": 45})).unwrap(); + select_prop_keys(&mut s, source, "transform/position"); + s.execute("prop.select", json!({"layer": source, "path": "transform/rotation", "add": true})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!({"keys": 2, "values": 1})); + let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("time.set", json!({"time": 2})).unwrap(); + s.execute("layer.select", json!({"layers": [target]})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(45.0)); + let position = prop(&s, target, "transform/position"); + assert_eq!( + position.keys.iter().map(|k| (k.time.seconds(), k.value.clone())).collect::>(), + vec![(2.0, KV::Vec3([10.0, 20.0, 0.0])), (3.0, KV::Vec3([30.0, 40.0, 0.0]))] + ); + s.execute("edit.undo", json!({})).unwrap(); + assert!(prop(&s, target, "transform/position").keys.is_empty()); + assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(0.0)); +} + #[test] fn cut_removes_keys_and_paste_restores_them() { let (mut s, l) = setup(); 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/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 4b5d1338..6167fbf8 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -126,6 +126,102 @@ fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: egui::Mod h.run_steps(2); } +#[test] +fn copy_and_paste_key_subsets_across_position_rotation_and_scale() { + for graph in [false, true] { + let (mut h, id, _) = harness(); + let paths = ["transform/position", "transform/rotation", "transform/scale"]; + for (path, times, values) in [ + (paths[0], [0.5, 1.0], [json!([100, 200, 0]), json!([300, 400, 0])]), + (paths[1], [0.5, 1.5], [json!(500), json!(900)]), + (paths[2], [0.0, 1.0], [json!([1500, 1600, 100]), json!([1700, 1800, 100])]), + ] { + for (time, value) in times.into_iter().zip(values) { + h.state_mut().session.execute("prop.addKey", json!({"layer": id.0, "path": path, "time": time, "value": value})).unwrap(); + } + } + let uids: Vec = paths.iter().map(|path| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap().uid).collect(); + let ctx = h.ctx.clone(); + for command in ["timeline.reveal.position", "timeline.revealAdd.rotation", "timeline.revealAdd.scale"] { + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, command, json!({})).unwrap(); + } + if graph { + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + for uid in &uids { + h.state_mut().session.execute("prop.select", json!({"layer": id.0, "prop": uid, "add": true, "selectKeys": false})).unwrap(); + } + h.state_mut().ui.timeline.graph_editor = true; + h.state_mut().ui.timeline.graph_mode = "value".into(); + } + h.run_steps(4); + let key_positions = |h: &Harness<'_, EffectcraftApp>, uid: u64| { + if !graph { + return keys(h, uid); + } + let mut positions: Vec = h + .state() + .auto + .query(&format!("timeline.graph.key.{uid}.0.")) + .iter() + .map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)) + .collect(); + positions.sort_by(|a, b| a.x.total_cmp(&b.x)); + positions + }; + let shift = egui::Modifiers { shift: true, ..Default::default() }; + for include_scale in [true, false] { + h.state_mut().session.execute("keys.select", json!({"keys": []})).unwrap(); + h.run_steps(2); + let original: Vec<_> = + paths.iter().map(|path| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap().keys.clone()).collect(); + let positions = key_positions(&h, uids[0]); + let rotations = key_positions(&h, uids[1]); + let scales = key_positions(&h, uids[2]); + click_with(&mut h, scales[0], Default::default()); + click_with(&mut h, positions[0], shift); + click_with(&mut h, rotations[1], shift); + if !include_scale { + click_with(&mut h, scales[1], shift); + click_with(&mut h, scales[0], shift); + click_with(&mut h, scales[1], shift); + } + let count = if include_scale { 3 } else { 2 }; + assert_eq!(h.state().session.state.selected_keys.len(), count); + assert_eq!(h.state().session.state.selected_props.len(), 3); + h.input_mut().events.push(Event::Copy); + h.step(); + let note = format!("EffectCraft: {count} keyframes"); + assert!(h.output().platform_output.commands.iter().any(|command| matches!(command, egui::OutputCommand::CopyText(text) if text == ¬e))); + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(2.0)); + let undo_steps = h.state().session.history.undo.len(); + h.input_mut().events.push(Event::Paste(note)); + h.run_steps(2); + assert_eq!(h.state().session.history.undo.len(), undo_steps + 1); + for (path, time, value) in + [(paths[0], if include_scale { 2.5 } else { 2.0 }, json!([100.0, 200.0, 0.0])), (paths[1], if include_scale { 3.5 } else { 3.0 }, json!(900.0))] + { + let p = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap(); + assert_eq!(p.keys.len(), 3); + assert_eq!(p.keys[2].time.seconds(), time); + assert_eq!(p.keys[2].value.to_json(), value); + } + let scale = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(paths[2]).unwrap(); + if include_scale { + assert_eq!(scale.keys.len(), 3); + assert_eq!(scale.keys[2].time.seconds(), 2.0); + assert_eq!(scale.keys[2].value.to_json(), json!([1500.0, 1600.0, 100.0])); + } else { + assert_eq!(scale.keys, original[2]); + } + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + for (path, expected) in paths.iter().zip(&original) { + assert_eq!(&h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap().keys, expected); + } + h.run_steps(2); + } + } +} + #[test] fn shift_click_toggles_and_ctrl_click_switches_interpolation() { let (mut h, id, uid) = harness(); From c2f790b1d967c658bfb7d4a2a682f6e17951eafe Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 15 Oct 2026 16:35:49 -0600 Subject: [PATCH 46/56] refactor(pdf): share structure parsing through a bounded adapter Load PDF document objects through the pinned craft-pdf parser and convert them into EffectCraft's existing drawing model with bounded traversal, recovery and retained data. Preserve generation numbers, stream limits, encrypted-document refusal and reported repairs. Retain app-owned drawing, EPS, fonts and content/image decoding. Assert early excessive-nesting rejection without partial art, and cover tolerant arrays on native and WASM. Validated with the22-stage local matrix and exact-head FreeBSD, Windows ARM64 and packaging checks. Co-authored-by: Codex --- Cargo.lock | 147 ++++++++++++++ Cargo.toml | 4 + NOTICE | 5 + ROADMAP.md | 1 + crates/pdf/Cargo.toml | 4 + crates/pdf/README.md | 51 ++++- crates/pdf/src/lib.rs | 15 +- crates/pdf/src/object.rs | 173 ++-------------- crates/pdf/src/read.rs | 350 +++++++++++++++++++++++++++++++++ crates/pdf/src/tests.rs | 35 +++- crates/pdf/src/tests_import.rs | 6 + crates/pdf/src/tests_reader.rs | 76 +++++++ docs/architecture.md | 2 + xtask/src/main.rs | 9 +- xtask/src/pdf_deps.rs | 117 +++++++++++ 15 files changed, 813 insertions(+), 182 deletions(-) create mode 100644 crates/pdf/src/read.rs create mode 100644 crates/pdf/src/tests_reader.rs create mode 100644 xtask/src/pdf_deps.rs diff --git a/Cargo.lock b/Cargo.lock index ce8fe123..b6ae1910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -747,6 +747,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.5.1" @@ -962,6 +968,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +dependencies = [ + "hayro-syntax", + "thiserror 2.0.21", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1478,6 +1493,7 @@ dependencies = [ name = "effectcraft-pdf" version = "0.10.0" dependencies = [ + "craft-pdf", "effectcraft-raster", "effectcraft-svg", "effectcraft-text", @@ -1485,6 +1501,7 @@ dependencies = [ "kurbo", "miniz_oxide 0.8.9", "skrifa", + "wasm-bindgen-test", "zune-jpeg", ] @@ -2678,6 +2695,40 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +dependencies = [ + "hayro-jbig2", + "hayro-jpeg2000", + "rustc-hash 2.1.3", + "smallvec", + "stringprep", + "zune-jpeg", +] + [[package]] name = "heck" version = "0.5.0" @@ -3167,6 +3218,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3311,6 +3372,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.5.1" @@ -3759,6 +3829,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "orbclient" version = "0.3.55" @@ -4773,6 +4849,17 @@ dependencies = [ "serde", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strum" version = "0.28.0" @@ -5156,6 +5243,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.1" @@ -5298,6 +5391,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5462,6 +5570,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" + [[package]] name = "wasm-encoder" version = "0.261.0" diff --git a/Cargo.toml b/Cargo.toml index 9f4e9d17..5bcb4bf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ rust-version = "1.95" repository = "https://github.com/storytold/effectcraft" [workspace.dependencies] +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } @@ -204,3 +205,6 @@ opt-level = 3 [profile.release] lto = "thin" codegen-units = 1 + +[patch.crates-io] +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" } diff --git a/NOTICE b/NOTICE index 65ce4c42..43bd2e2a 100644 --- a/NOTICE +++ b/NOTICE @@ -15,3 +15,8 @@ list (author, source, license) is in ATTRIBUTION.md. Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt. JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt. + +The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0. +The shared patched syntax source is centralized in storytold/craft-libs, transferred from +PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no +Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters. diff --git a/ROADMAP.md b/ROADMAP.md index a579491a..e21039c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -154,6 +154,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | +| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | | 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md | | 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 | diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 9b761f57..b37dff50 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-pdf.workspace = true effectcraft-svg = { workspace = true } kurbo = { workspace = true } effectcraft-text = { workspace = true } @@ -23,3 +24,6 @@ zune-jpeg = "0.5" # Encodes JPEG fixtures for the DCT image test. image = { workspace = true } effectcraft-raster = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/crates/pdf/README.md b/crates/pdf/README.md index b7678b09..57603cf9 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -14,20 +14,23 @@ Charstring Format) for CFF font programs; **Adobe Type 1 Font Format** (version font programs; the **PostScript Language Reference, third edition** and the **Encapsulated PostScript File Format Specification 3.0** for EPS; **ITU-T T.4** (07/2003) and **ITU-T T.6** (11/1988) for CCITT fax images. TrueType font programs are read through skrifa and JPEG (DCT) -images through zune-jpeg (both permissively licensed, pure Rust). No third-party -PDF/PostScript/font/codec code was read; test files (including the CFF and Type 1 fonts and the -CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was +images through zune-jpeg (both permissively licensed, pure Rust). PDF document/object loading uses the shared `craft-pdf` parser described below. The retained +drawing, PostScript, font and filter implementations were developed from the public +specifications without copying third-party implementations. Test files (including the CFF +and Type 1 fonts and the CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was produced once by libtiff's `tiffcp` (an external encoder used only as a fixture generator, like ffmpeg elsewhere) to check the code tables against an independent implementation. ## What is read -- **Structure:** indirect objects found by scanning (damaged or incremental cross-reference - sections do not matter), object streams, the page tree with inherited resources, MediaBox / - CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85, - RunLength, CCITTFax (Group 3 one- and two-dimensional, Group 4; `K`, `Columns`, `Rows`, - `EndOfLine`, `EncodedByteAlign`, `EndOfBlock`, `BlackIs1`; damaged data keeps the rows - decoded before the damage). Encrypted files are refused. +- **Structure:** `craft-pdf` resolves cross-reference tables/streams and indirect objects into + the app's existing object model. References retain object and generation numbers. A bounded + compatibility repair recovers referenced objects from object streams when damaged xrefs omit + them; every recovery or unresolved reference is reported in `Doc::skipped` (up to 64 messages). + The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`. + Content/image filters remain app-owned: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85, + RunLength and CCITTFax. Encrypted files remain refused; `page_count` returns zero when the + reader refuses a document. - **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`, `LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd), clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by @@ -129,3 +132,33 @@ ffmpeg elsewhere) to check the code tables against an independent implementation layer; the shapes drawn between images go to shape layers of their own (` Outlines `), so the layer stack keeps the document's paint order. Soft masks, and the clips and masks of images, are not converted (the command's `skipped` list says so). + +## Shared parser setup and bounds + +The workspace pins `craft-pdf` to `craft-libs` Git revision +`ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d`, with `default-features = false` and only the explicit `read` +feature. This excludes the backend's bundled fonts and Adobe CMaps and preserves EffectCraft's +font and asset policies. The shared parser does not change the supported drawing operators, +image codecs, continuously rasterized SVG tree, editable shapes, or EPS interpreter. +This revision removes an unchecked lazy-array read and preserves later values and pages after +sign-only numeric entries; the app exercises those inputs through its object adapter, public +page parser and rasterizer on native and WASM targets. + +The root Cargo patch pins Hayro-syntax to the same `storytold/craft-libs` revision. `cargo xtask pdf-deps` checks this graph and refuses +an enabled Hayro interpreter, rasterizer or CMap dependency. The shared revision must be +published before a fresh app checkout can resolve it; no sibling checkout is required. + +Input is limited to 512 MiB. The object adapter admits at most 100,000 document/referenced +objects, 1,000,000 converted values and 64 inline nesting levels, with a 512 MiB retained +object-data budget. References are queued once rather than recursively expanded. Native +unwinding panics during shared loading or lazy object conversion become an error and discard +partial state. These limits are not an overall process-memory or execution-time guarantee; +WASM aborts and process-wide allocation failures cannot be caught. Missing or repaired objects +are reported rather than silently presented as a complete import. + +The shared parser receives EffectCraft's existing ordinary object/xref stream limit at open: +256 MiB on 64-bit targets and 64 MiB on 32-bit targets, including WASM. Immutable document +limits apply during normal parsing, initial xrefs, fallback and repair. Malformed object-stream +compatibility recovery also obeys that limit and the adapter's remaining retained-data budget. +Content/image filter decoding still uses EffectCraft's existing bounded filter chain; the +retained-data cap is not a bound on every parser cache or temporary allocation. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 39d15074..f4616a9a 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -19,6 +19,7 @@ mod image; mod object; mod page; mod ps; +mod read; mod shading; mod type1; pub mod write; @@ -33,6 +34,7 @@ pub enum Error { NoPages, PageOutOfRange(usize), NoBoundingBox, + Read(String), } impl std::fmt::Display for Error { @@ -41,8 +43,9 @@ impl std::fmt::Display for Error { Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"), Error::Encrypted => write!(f, "encrypted PDF files are not supported"), Error::NoPages => write!(f, "the PDF has no pages"), - Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n + 1), + Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n.saturating_add(1)), Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"), + Error::Read(message) => f.write_str(message), } } } @@ -99,7 +102,7 @@ pub fn parse_page(bytes: &[u8], index: usize) -> Result { /// Number of pages (1 for EPS). pub fn page_count(bytes: &[u8]) -> usize { match sniff(bytes) { - Some(Format::Pdf) => page::pages(&object::File::parse(bytes)).len(), + Some(Format::Pdf) => object::File::parse(bytes).map(|file| page::pages(&file).len()).unwrap_or(0), Some(Format::Eps) => 1, None => 0, } @@ -140,10 +143,7 @@ fn finish(nodes: Vec, flags: Vec, skipped: Vec, width: f64, } fn parse_pdf(bytes: &[u8], index: usize) -> Result { - let file = object::File::parse(bytes); - if file.encrypted() { - return Err(Error::Encrypted); - } + let file = object::File::parse(bytes)?; let pages = page::pages(&file); if pages.is_empty() { return Err(Error::NoPages); @@ -160,6 +160,7 @@ fn parse_pdf(bytes: &[u8], index: usize) -> Result { }; let content = page::page_content(&file, pg); let mut it = page::Interp::new(&file, pg.bbox); + it.b.skipped.extend(file.repairs.iter().cloned()); it.run(&content, &pg.resources, Affine::IDENTITY, 0); let (nodes, flags, skipped) = it.b.finish(); Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc)) @@ -201,4 +202,6 @@ mod tests_groups; #[cfg(test)] mod tests_import; #[cfg(test)] +mod tests_reader; +#[cfg(test)] mod tests_shading; diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs index b9016222..cdbb9733 100644 --- a/crates/pdf/src/object.rs +++ b/crates/pdf/src/object.rs @@ -1,9 +1,6 @@ -//! PDF objects (ISO 32000-1 §7.2–7.5): the lexer, the object model, stream filters and the -//! indirect-object table. -//! -//! The table is built by scanning the file for `n g obj` headers (later definitions win, as -//! incremental updates do) and by unpacking object streams, so damaged or rewritten -//! cross-reference sections do not matter. +//! Local object model, content-stream lexer and stream filters. Document objects are read +//! through the shared parser in `read`; the local lexer remains for content operators and +//! PostScript-related data. use std::collections::HashMap; @@ -191,7 +188,7 @@ impl<'a> Lexer<'a> { } /// `n g R` after a number inside arrays and dictionaries. - fn maybe_ref(&mut self, v: Obj) -> Obj { + pub(super) fn maybe_ref(&mut self, v: Obj) -> Obj { let Obj::Num(n) = v else { return v }; if n.fract() != 0.0 || n < 0.0 { return v; @@ -312,11 +309,10 @@ pub fn parse_num(w: &[u8]) -> Option { /// growing buffer can reserve about twice the limit. const MAX_INFLATED: usize = if cfg!(target_pointer_width = "32") { 256 << 20 } else { 1 << 30 }; -/// The tighter bound for object streams, which [`File::parse`] decodes eagerly (all of them, -/// before any page is chosen). They hold only other objects' dictionaries, arrays, numbers and -/// strings, so real ones are far smaller; PdfCraft uses 256 MiB for the object and -/// cross-reference streams it decodes while loading. A quarter on 32-bit targets, as above. -const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 }; +/// The bound for ordinary object/xref streams and malformed object-stream recovery. +/// The shared parser receives this immutable limit when the document is opened; +/// 32-bit targets retain the app's quarter-sized limit. +pub(crate) const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 }; /// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT, /// JBIG2) give `None`. @@ -624,151 +620,14 @@ fn predict(data: &[u8], p: &Dict, max: usize) -> Option> { /// Every indirect object of a PDF file, plus the trailer dictionaries found. pub struct File { - pub objects: HashMap, + pub objects: HashMap<(u32, u16), Obj>, pub trailers: Vec, -} - -fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { - if from >= hay.len() || needle.is_empty() { - return None; - } - hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from) + pub repairs: Vec, } impl File { - pub fn parse(data: &[u8]) -> File { - let mut f = File { objects: HashMap::new(), trailers: vec![] }; - let mut i = 0; - while let Some(p) = find(data, b"obj", i) { - i = p + 3; - if p >= 3 && &data[p - 3..p] == b"end" { - continue; - } - if data.get(p + 3).is_some_and(|c| !is_white(*c) && !is_delim(*c)) { - continue; - } - // Walk back over "num gen ". - let mut q = p; - let back_ws = |q: &mut usize| { - let s = *q; - while *q > 0 && is_white(data[*q - 1]) { - *q -= 1; - } - *q < s - }; - let back_digits = |q: &mut usize| { - let e = *q; - while *q > 0 && data[*q - 1].is_ascii_digit() { - *q -= 1; - } - (*q < e).then(|| std::str::from_utf8(&data[*q..e]).ok()?.parse::().ok()).flatten() - }; - if !back_ws(&mut q) { - continue; - } - let Some(_gen) = back_digits(&mut q) else { continue }; - if !back_ws(&mut q) { - continue; - } - let Some(num) = back_digits(&mut q) else { continue }; - if q > 0 && !is_white(data[q - 1]) && !is_delim(data[q - 1]) { - continue; - } - let mut lx = Lexer::new(data, p + 3); - let Some(obj) = lx.next() else { continue }; - let obj = lx.maybe_ref(obj); - let obj = match obj { - Obj::Dict(d) => { - let save = lx.pos; - lx.skip_ws(); - if data.get(lx.pos..).is_some_and(|d| d.starts_with(b"stream")) { - let mut s = lx.pos + 6; - if data.get(s) == Some(&b'\r') { - s += 1; - } - if data.get(s) == Some(&b'\n') { - s += 1; - } - let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize); - let s = s.min(data.len()); - let end = match declared.filter(|l| { - s.checked_add(*l).is_some_and(|e| e <= data.len()) && { - let mut k = s + l; - while k < data.len() && is_white(data[k]) { - k += 1; - } - data[k..].starts_with(b"endstream") - } - }) { - Some(l) => s + l, - None => { - let mut e = find(data, b"endstream", s).unwrap_or(data.len()); - if e > s && data[e - 1] == b'\n' { - e -= 1; - } - if e > s && data[e - 1] == b'\r' { - e -= 1; - } - e - } - }; - let end = end.max(s); - i = end; - Obj::Stream(d, data[s..end].to_vec()) - } else { - lx.pos = save; - Obj::Dict(d) - } - } - o => o, - }; - if let Obj::Stream(d, _) = &obj - && d.get("Type").and_then(Obj::name) == Some("XRef") - { - f.trailers.push(d.clone()); - } - f.objects.insert(num, obj); - } - // Trailer dictionaries of classic cross-reference sections. - let mut i = 0; - while let Some(p) = find(data, b"trailer", i) { - i = p + 7; - let mut lx = Lexer::new(data, i); - if let Some(Obj::Dict(d)) = lx.next() { - f.trailers.push(d); - } - } - // Object streams. - let streams: Vec<(Dict, Vec)> = f - .objects - .values() - .filter_map(|o| match o { - Obj::Stream(d, raw) if d.get("Type").and_then(Obj::name) == Some("ObjStm") => Some((d.clone(), raw.clone())), - _ => None, - }) - .collect(); - for (d, raw) in streams { - let Some(dec) = decode_stream_within(&f, &d, &raw, MAX_OBJECT_STREAM) else { continue }; - let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize; - let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize; - let mut lx = Lexer::new(&dec, 0); - let mut heads = vec![]; - for _ in 0..n.min(100_000) { - let (Some(Obj::Num(num)), Some(Obj::Num(off))) = (lx.next(), lx.next()) else { break }; - heads.push((num as u32, off as usize)); - } - for (num, off) in heads { - if f.objects.contains_key(&num) || first + off >= dec.len() { - continue; - } - let mut lx = Lexer::new(&dec, first + off); - if let Some(o) = lx.next() { - let o = lx.maybe_ref(o); - f.objects.insert(num, o); - } - } - } - f + pub fn parse(data: &[u8]) -> Result { + crate::read::open(data) } /// Follow references (bounded). @@ -776,7 +635,7 @@ impl File { let mut o = o; for _ in 0..32 { match o { - Obj::Ref(n, _) => match self.objects.get(n) { + Obj::Ref(n, g) => match self.objects.get(&(*n, *g)) { Some(x) => o = x, None => return &Obj::Null, }, @@ -810,13 +669,9 @@ impl File { return Some(d); } } - let mut cats: Vec<(&u32, &Obj)> = + let mut cats: Vec<(&(u32, u16), &Obj)> = self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect(); cats.sort_by_key(|(n, _)| **n); cats.last().and_then(|(_, o)| o.dict()) } - - pub fn encrypted(&self) -> bool { - self.trailers.iter().any(|t| t.contains_key("Encrypt")) - } } diff --git a/crates/pdf/src/read.rs b/crates/pdf/src/read.rs new file mode 100644 index 00000000..953582fc --- /dev/null +++ b/crates/pdf/src/read.rs @@ -0,0 +1,350 @@ +//! Adapter from the shared PDF syntax API to the existing drawing interpreter's object model. +//! Only reachable objects are retained, references keep their generation, and native parser +//! panics (including lazy object reads) discard the entire partially populated adapter. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; + +use craft_pdf::syntax::object::{Dict as PdfDict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier, Stream}; +use craft_pdf::syntax::xref::XRef; + +use crate::Error; +use crate::object::{Dict, File, Lexer, Obj}; + +const MAX_OBJECTS: usize = 100_000; +const MAX_VALUES: usize = 1_000_000; +const MAX_DEPTH: usize = 64; +const MAX_BYTES: usize = 512 * 1024 * 1024; + +fn limit(what: &str) -> Error { + Error::Read(format!("PDF exceeds the {what} import limit")) +} + +fn allocation() -> Error { + Error::Read("PDF object storage could not be allocated".into()) +} + +fn open_options() -> craft_pdf::OpenOptions { + craft_pdf::OpenOptions { + decode_limits: craft_pdf::syntax::DecodeLimits::default().with_max_decoded_stream(crate::object::MAX_OBJECT_STREAM), + ..Default::default() + } +} + +pub(crate) fn open(bytes: &[u8]) -> Result { + craft_pdf::check_input_size_with(bytes.len(), &open_options()).map_err(|e| Error::Read(e.to_string()))?; + catch_unwind(AssertUnwindSafe(|| open_inner(bytes))).map_err(|_| Error::Read("PDF parser failed internally".into()))? +} + +fn open_inner(bytes: &[u8]) -> Result { + // Preserve the app's refusal of encrypted PDFs, including empty-user-password files. + // Classic trailer dictionaries use the existing lexer; xref-stream dictionaries below + // are read by the shared parser. This does not parse document objects a second time. + let mut markers = 0; + for (offset, word) in bytes.windows(7).enumerate() { + if word == b"trailer" { + markers += 1; + if markers > MAX_OBJECTS { + return Err(limit("trailer count")); + } + if let Some(Obj::Dict(dict)) = Lexer::new(bytes, offset + 7).next() + && dict.contains_key("Encrypt") + { + return Err(Error::Encrypted); + } + } + } + let pdf = craft_pdf::open_slice_with_options(bytes, None, &open_options()).map_err(|error| match error { + craft_pdf::OpenError::Load(craft_pdf::syntax::LoadPdfError::Decryption(_)) => Error::Encrypted, + craft_pdf::OpenError::Load(craft_pdf::syntax::LoadPdfError::Invalid) => Error::NoPages, + other => Error::Read(other.to_string()), + })?; + let mut object_streams = Vec::new(); + for (index, object) in pdf.objects().into_iter().enumerate() { + if index >= MAX_OBJECTS { + return Err(limit("100000 objects")); + } + if let Object::Stream(stream) = object { + let dict = stream.dict(); + if dict.get::>(b"Type").is_some_and(|name| name.as_ref() == b"XRef") && dict.contains_key(b"Encrypt") { + return Err(Error::Encrypted); + } + if dict.get::>(b"Type").is_some_and(|name| name.as_ref() == b"ObjStm") { + object_streams.try_reserve(1).map_err(|_| allocation())?; + object_streams.push(stream); + } + } + } + let mut reader = Reader { + xref: pdf.xref(), + pending: VecDeque::new(), + seen: HashSet::new(), + values_left: MAX_VALUES, + bytes_left: MAX_BYTES, + object_streams, + recovery: None, + }; + let root = pdf.xref().root_id(); + let root = reader.reference(ObjRef::new(root.obj_number, root.gen_number))?; + let mut file = File { objects: HashMap::new(), trailers: vec![Dict::from([("Root".into(), root)])], repairs: Vec::new() }; + while let Some(id) = reader.pending.pop_front() { + let Some(key) = key(id) else { continue }; + let object = match reader.xref.get::>(id) { + Some(value) => reader.object(value, 0)?, + None => { + let recovered = reader.recover(id)?; + if file.repairs.len() < 64 { + let action = if recovered.is_some() { "recovered PDF object from an object stream" } else { "unresolved PDF object" }; + file.repairs.push(format!("{action}: {} {}", id.obj_number, id.gen_number)); + } + recovered.unwrap_or(Obj::Null) + } + }; + file.objects.try_reserve(1).map_err(|_| allocation())?; + file.objects.insert(key, object); + } + Ok(file) +} + +fn key(id: ObjectIdentifier) -> Option<(u32, u16)> { + Some((u32::try_from(id.obj_number).ok()?, u16::try_from(id.gen_number).ok()?)) +} + +struct Reader<'a> { + xref: &'a XRef, + pending: VecDeque, + seen: HashSet, + values_left: usize, + bytes_left: usize, + object_streams: Vec>, + recovery: Option>>, +} + +struct Recovered { + bytes: Arc>, + start: usize, + end: usize, +} + +impl Reader<'_> { + fn value(&mut self) -> Result<(), Error> { + self.values_left = self.values_left.checked_sub(1).ok_or_else(|| limit("1000000 values"))?; + Ok(()) + } + + fn bytes(&mut self, bytes: &[u8]) -> Result, Error> { + self.bytes_left = self.bytes_left.checked_sub(bytes.len()).ok_or_else(|| limit("512 MiB object data"))?; + let mut out = Vec::new(); + out.try_reserve_exact(bytes.len()).map_err(|_| allocation())?; + out.extend_from_slice(bytes); + Ok(out) + } + + /// The previous importer could recover compressed objects incorrectly marked free in + /// an otherwise readable xref. Retain that narrow repair only for unresolved references, + /// with a warning, bounded decoding and the existing content/object lexer. + fn recover(&mut self, id: ObjectIdentifier) -> Result, Error> { + if id.gen_number != 0 { + return Ok(None); + } + if self.recovery.is_none() { + let mut recovered: HashMap> = HashMap::new(); + for stream in std::mem::take(&mut self.object_streams) { + let Some(count) = stream.dict().get::(b"N") else { continue }; + let Some(first) = stream.dict().get::(b"First") else { continue }; + if count as usize > MAX_OBJECTS.saturating_sub(recovered.len()) { + return Err(limit("100000 recovered objects")); + } + let decoded = match stream.decoded_within(self.bytes_left.min(crate::object::MAX_OBJECT_STREAM)) { + Ok(data) => data, + Err(_) => continue, + }; + self.bytes_left = self.bytes_left.checked_sub(decoded.len()).ok_or_else(|| limit("512 MiB object data"))?; + let decoded = Arc::new(decoded.into_owned()); + let mut lexer = Lexer::new(&decoded, 0); + let mut offsets = Vec::new(); + for _ in 0..count { + self.value()?; + let (Some(Obj::Num(number)), Some(Obj::Num(offset))) = (lexer.next(), lexer.next()) else { break }; + if !number.is_finite() || number.fract() != 0.0 || !(0.0..=f64::from(u32::MAX)).contains(&number) { + continue; + } + if !offset.is_finite() || offset.fract() != 0.0 || offset < 0.0 || offset > decoded.len() as f64 { + continue; + } + let Some(start) = (first as usize).checked_add(offset as usize).filter(|start| *start < decoded.len()) else { continue }; + offsets.try_reserve(1).map_err(|_| allocation())?; + offsets.push((start, number as u32)); + } + offsets.sort_unstable(); + for (index, &(start, number)) in offsets.iter().enumerate() { + let end = offsets.get(index + 1).map_or(decoded.len(), |(next, _)| *next); + recovered.try_reserve(1).map_err(|_| allocation())?; + recovered.entry(number).and_modify(|entry| *entry = None).or_insert_with(|| Some(Recovered { bytes: Arc::clone(&decoded), start, end })); + } + } + self.recovery = Some(recovered); + } + let Some(number) = u32::try_from(id.obj_number).ok() else { return Ok(None) }; + let Some(location) = self.recovery.as_ref().and_then(|r| r.get(&number)) else { return Ok(None) }; + let Some(location) = location else { return Err(Error::Read(format!("PDF has ambiguous recovered object {number}"))) }; + let bytes = Arc::clone(&location.bytes); + let Some(bytes) = bytes.get(location.start..location.end) else { return Ok(None) }; + let mut lexer = Lexer::new(bytes, 0); + let Some(object) = lexer.next() else { return Ok(None) }; + let object = lexer.maybe_ref(object); + self.register_recovered(&object, 0)?; + Ok(Some(object)) + } + + fn register_recovered(&mut self, object: &Obj, depth: usize) -> Result<(), Error> { + self.value()?; + if depth > MAX_DEPTH { + return Err(limit("64 nested objects")); + } + let bytes = match object { + Obj::Name(text) | Obj::Op(text) => text.len(), + Obj::Str(bytes) => bytes.len(), + Obj::Array(values) => { + for value in values { + self.register_recovered(value, depth + 1)?; + } + 0 + } + Obj::Dict(dict) | Obj::Stream(dict, _) => { + for (name, value) in dict { + self.bytes_left = self.bytes_left.checked_sub(name.len()).ok_or_else(|| limit("512 MiB object data"))?; + self.register_recovered(value, depth + 1)?; + } + 0 + } + Obj::Ref(number, generation) => { + if let Ok(number) = i32::try_from(*number) { + self.reference(ObjRef::new(number, i32::from(*generation)))?; + } + 0 + } + _ => 0, + }; + self.bytes_left = self.bytes_left.checked_sub(bytes).ok_or_else(|| limit("512 MiB object data"))?; + Ok(()) + } + + fn name(&mut self, bytes: &[u8]) -> Result { + // Lossy UTF-8 expansion is at most three output bytes per source byte. + let bound = bytes.len().checked_mul(3).ok_or_else(|| limit("name length"))?; + self.bytes_left = self.bytes_left.checked_sub(bound).ok_or_else(|| limit("512 MiB object data"))?; + Ok(String::from_utf8_lossy(bytes).into_owned()) + } + + fn reference(&mut self, reference: ObjRef) -> Result { + self.value()?; + let id = ObjectIdentifier::new(reference.obj_number, reference.gen_number); + let Some((number, generation)) = key(id) else { return Ok(Obj::Null) }; + if !self.seen.contains(&id) { + if self.seen.len() >= MAX_OBJECTS { + return Err(limit("100000 referenced objects")); + } + self.seen.try_reserve(1).map_err(|_| allocation())?; + self.pending.try_reserve(1).map_err(|_| allocation())?; + self.seen.insert(id); + self.pending.push_back(id); + } + Ok(Obj::Ref(number, generation)) + } + + fn maybe(&mut self, object: MaybeRef>, depth: usize) -> Result { + match object { + MaybeRef::Ref(reference) => self.reference(reference), + MaybeRef::NotRef(object) => self.object(object, depth), + } + } + + fn dictionary(&mut self, dict: &PdfDict<'_>, depth: usize) -> Result { + let mut out = Dict::new(); + for (name, object) in dict.entries() { + let name = self.name(name.as_ref())?; + let object = self.maybe(object, depth + 1)?; + out.try_reserve(1).map_err(|_| allocation())?; + out.insert(name, object); + } + Ok(out) + } + + fn object(&mut self, object: Object<'_>, depth: usize) -> Result { + self.value()?; + if depth > MAX_DEPTH { + return Err(limit("64 nested objects")); + } + Ok(match object { + Object::Null(_) => Obj::Null, + Object::Boolean(value) => Obj::Bool(value), + Object::Number(value) => Obj::Num(value.as_f64()), + Object::String(value) => Obj::Str(self.bytes(value.as_bytes())?), + Object::Name(value) => Obj::Name(self.name(value.as_ref())?), + Object::Array(array) => { + let mut out = Vec::new(); + for value in array.raw_iter() { + let value = self.maybe(value, depth + 1)?; + out.try_reserve(1).map_err(|_| allocation())?; + out.push(value); + } + Obj::Array(out) + } + Object::Dict(dict) => Obj::Dict(self.dictionary(&dict, depth)?), + Object::Stream(stream) => { + let dict = self.dictionary(stream.dict(), depth)?; + Obj::Stream(dict, self.bytes(&stream.raw_data())?) + } + }) + } +} + +#[cfg(test)] +mod limit_tests { + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn shared_reader_preserves_tolerant_array_numbers_and_later_pages() { + use crate::object::Obj; + + // Array validation accepts these sign/dot-only zero tokens. Previously '+' + // reached an unchecked lazy-iterator read: native loading returned an internal + // parser error and WASM could abort before reaching the following object. + for token in ["+", "-", ".", "+.", "-."] { + for numeric_kid in [false, true] { + let kids = if numeric_kid { format!("{token} 3 0 R") } else { "3 0 R".into() }; + let bytes = crate::write::pdf( + &[ + (1, format!("<< /Type /Catalog /Pages 2 0 R /Probe [{token} 3] >>"), None), + (2, format!("<< /Type /Pages /Kids [{kids}] /Count 1 >>"), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Resources << >> /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 40 20 re f\n".to_vec())), + ], + 1, + ); + let file = super::open(&bytes).unwrap(); + assert_eq!(file.catalog().unwrap().get("Probe").and_then(Obj::array), Some([Obj::Num(0.0), Obj::Num(3.0)].as_slice())); + assert_eq!(crate::page_count(&bytes), 1, "token {token}, numeric kid {numeric_kid}"); + let doc = crate::parse(&bytes).unwrap(); + assert_eq!((doc.width, doc.height), (40.0, 20.0)); + assert_eq!(doc.flatten().len(), 1); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(effectcraft_svg::rasterize(&doc, 40, 20, 1.0).get(20, 10), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(crate::parse_page(&bytes, 1), Err(crate::Error::PageOutOfRange(1))); + } + } + } + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn shared_reader_preserves_the_target_stream_budget() { + let options = super::open_options(); + let expected = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 }; + assert_eq!(options.decode_limits.max_decoded_stream(), expected); + assert_eq!(options.max_input_bytes, 512 << 20); + let bytes = b"%PDF-1.7\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 40 20] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + let pdf = craft_pdf::open_slice_with_options(bytes, None, &options).unwrap(); + assert_eq!(pdf.decode_limits().max_decoded_stream(), expected); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 57b7e044..a3dcbb21 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -48,7 +48,10 @@ fn pdf_paths_clip_gradient_and_layers() { assert_eq!(page_count(&bytes), 1); let doc = parse(&bytes).unwrap(); assert_eq!((doc.width, doc.height), (200.0, 100.0)); - assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}"); + assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}, skipped: {:?}", doc.skipped); + if objstm { + assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object")), "the malformed xref repair is reported"); + } assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped); let (w, h) = doc.pixel_size(); let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); @@ -196,7 +199,7 @@ fn nested_bomb_in_the_second_flate_filter_is_refused() { const MAX: usize = 1 << 20; let flate = object::Obj::Name("FlateDecode".into()); let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect(); - let file = object::File::parse(b""); + let file = object_file(vec![]); let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6); nested.resize(MAX - 1, 0); let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6); @@ -206,7 +209,7 @@ fn nested_bomb_in_the_second_flate_filter_is_refused() { fn bounded_filter(dict: &str, raw: &[u8], max: usize) -> Option> { let object::Obj::Dict(d) = object::Lexer::new(dict.as_bytes(), 0).next().unwrap() else { panic!("not a dictionary") }; - object::decode_stream_within(&object::File::parse(b""), &d, raw, max) + object::decode_stream_within(&object_file(vec![]), &d, raw, max) } #[test] @@ -660,9 +663,31 @@ fn tiling_patterns_coloured_and_uncoloured() { assert_eq!(at(115, 15)[3], 0.0); } +/// Build isolated interpreter objects without presenting a function or colour space as a +/// document catalog. Complete-document parsing is covered by the page and reader fixtures. +fn object_file(objs: Objs) -> object::File { + let objects = objs + .into_iter() + .map(|(number, body, stream)| { + let mut lexer = object::Lexer::new(body.as_bytes(), 0); + let value = lexer.next().unwrap(); + let value = lexer.maybe_ref(value); + let value = match stream { + Some(bytes) => { + let object::Obj::Dict(dict) = value else { panic!("stream fixture must have a dictionary") }; + object::Obj::Stream(dict, bytes) + } + None => value, + }; + ((number, 0), value) + }) + .collect(); + object::File { objects, trailers: vec![], repairs: vec![] } +} + /// Object 1 of a file holding just `objs`, read as a function. fn read_function(objs: Objs) -> color::Func { - color::Func::read(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0)) + color::Func::read(&object_file(objs), &object::Obj::Ref(1, 0)) } /// A sampled (type 0) function, read from a file holding just its stream. @@ -672,7 +697,7 @@ fn read_sampled_function(dict: &str, data: Vec) -> color::Func { /// Object 1 of a file holding just `objs`, read as a colour space. fn read_color_space(objs: Objs) -> color::Cs { - color::color_space(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0), None) + color::color_space(&object_file(objs), &object::Obj::Ref(1, 0), None) } #[test] diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index add447e3..e53727ef 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -377,6 +377,12 @@ fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() { } } +#[test] +fn extreme_page_indices_have_a_printable_error() { + let text = Error::PageOutOfRange(usize::MAX).to_string(); + assert!(text.starts_with("the PDF has no page ")); +} + #[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 diff --git a/crates/pdf/src/tests_reader.rs b/crates/pdf/src/tests_reader.rs new file mode 100644 index 00000000..999a9900 --- /dev/null +++ b/crates/pdf/src/tests_reader.rs @@ -0,0 +1,76 @@ +//! The shared object adapter preserves scalar references and bounds both tree and graph input. + +use crate::{Error, parse}; + +fn document(extra: &str, tail: &[(u32, u16, String)]) -> Vec { + let content = "/GS gs 1 0 0 rg 0 0 20 10 re f"; + let mut objects = vec![ + (1, 0, format!("<< /Type /Catalog /Pages 2 0 R {extra} >>")), + (2, 0, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()), + (3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20 10] /Resources << /ExtGState << /GS << /ca 5 7 R >> >> >> /Contents 4 0 R >>".into()), + (4, 0, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())), + (5, 7, "0.25".into()), + ]; + objects.extend_from_slice(tail); + let mut data = b"%PDF-1.7\n".to_vec(); + let mut offsets = std::collections::BTreeMap::new(); + for (number, generation, value) in objects { + offsets.insert(number, (data.len(), generation)); + data.extend_from_slice(format!("{number} {generation} obj\n{value}\nendobj\n").as_bytes()); + } + let xref = data.len(); + let count = offsets.keys().last().copied().unwrap() + 1; + data.extend_from_slice(format!("xref\n0 {count}\n0000000000 65535 f \n").as_bytes()); + for number in 1..count { + let entry = offsets.get(&number).map_or_else(|| "0000000000 65535 f \n".into(), |(offset, generation)| format!("{offset:010} {generation:05} n \n")); + data.extend_from_slice(entry.as_bytes()); + } + data.extend_from_slice(format!("trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + data +} + +#[test] +fn a_nonzero_generation_scalar_keeps_the_drawing_opacity() { + let doc = parse(&document("", &[])).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let pixel = effectcraft_svg::rasterize(&doc, 20, 10, 1.0).get(10, 5); + assert!((pixel[0] - 0.25).abs() < 0.001 && (pixel[3] - 0.25).abs() < 0.001, "{pixel:?}"); +} + +#[test] +fn a_long_cyclic_reference_graph_does_not_expand_recursively() { + let objects: Vec<_> = (10..1010).map(|id| (id, 0, format!("<< /Next {} 0 R >>", if id == 1009 { 10 } else { id + 1 }))).collect(); + let plain = parse(&document("", &[])).unwrap(); + let cyclic = parse(&document("/Extra 10 0 R", &objects)).unwrap(); + assert_eq!(plain, cyclic); +} + +#[test] +fn excessive_inline_nesting_returns_an_error_instead_of_partial_art() { + let extra = format!("/Extra {}0{}", "[".repeat(80), "]".repeat(80)); + let bytes = document(&extra, &[]); + // The shared parser rejects the catalog before the adapter's depth limit runs. + // Its Invalid result follows the existing NoPages mapping, with no partial document. + assert_eq!(crate::page_count(&bytes), 0); + assert_eq!(parse(&bytes), Err(Error::NoPages)); +} + +fn object_stream(entries: &[(u32, &str)]) -> String { + let (mut header, mut body) = (String::new(), String::new()); + for (number, value) in entries { + header.push_str(&format!("{number} {} ", body.len())); + body.push_str(value); + body.push(' '); + } + let first = header.len(); + let content = header + &body; + format!("<< /Type /ObjStm /N {} /First {first} /Length {} >>\nstream\n{content}\nendstream", entries.len(), content.len()) +} + +#[test] +fn duplicate_unreferenced_compressed_objects_do_not_block_repair() { + let objects = vec![(20, 0, object_stream(&[(10, "<< /Value 1 >>"), (30, "null")])), (21, 0, object_stream(&[(30, "42")]))]; + let doc = parse(&document("/Missing 10 0 R", &objects)).unwrap(); + assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object from an object stream: 10 0"))); + assert!(matches!(parse(&document("/Missing 30 0 R", &objects)), Err(Error::Read(message)) if message.contains("ambiguous recovered object 30"))); +} diff --git a/docs/architecture.md b/docs/architecture.md index e5564c22..72daec32 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -721,3 +721,5 @@ JSON parsing is now the bulk of open), and rendering 5,000-layer comps faster. | Date | Change | Summary | |---|---|---| | 2026-10-10 | minor | Status line added; crate table checked against `crates/` (every crate listed); effect count and VP9 encoder description corrected | + +PDF document structure uses the `craft-pdf` read feature through an app-owned bounded object adapter. EffectCraft retains its drawing tree, fonts, content/image filters and EPS interpreter; ordinary object/xref limits remain 256 MiB on 64-bit and 64 MiB on 32-bit/WASM, including repair. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 4d541feb..e79ac63a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,4 +1,4 @@ -//! Workspace automation: `cargo xtask `. +//! Workspace automation: `cargo xtask `. //! //! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges, //! listed same-layer edges, no UI/OS crates below L5). @@ -9,12 +9,13 @@ //! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete //! `.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). -//! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. +//! - `ci`: fmt check, clippy -D warnings, tests, PDF dependency policy, layers, assets, wasm. //! - `bench [--bin CLI] [--quick] [--compare OLD.jsonl] …`: render benchmark of generated scenarios //! (memory, CPU, wall time) into `target/bench/results.jsonl` (`bench.rs`, docs/testing.md). mod bench; mod ico; +mod pdf_deps; mod version; use std::process::{Command, ExitCode}; @@ -410,6 +411,7 @@ fn ci() -> Result<(), String> { run(Command::new(cargo).args(["fmt", "--check"]))?; run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?; run(Command::new(cargo).args(["test", "--workspace", "--release"]))?; + pdf_deps::run(&root())?; layers()?; assets()?; wasm() @@ -421,6 +423,7 @@ fn main() -> ExitCode { "layers" => layers(), "wasm" => wasm(), "assets" => assets(), + "pdf-deps" => pdf_deps::run(&root()), "ci" => ci(), "web" => web(&std::env::args().skip(2).collect::>()), "bench" => bench::run(&root(), &std::env::args().skip(2).collect::>()), @@ -432,7 +435,7 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs new file mode 100644 index 00000000..d7f88079 --- /dev/null +++ b/xtask/src/pdf_deps.rs @@ -0,0 +1,117 @@ +//! Pin the shared parser and reject interpreter features that would embed backend assets. + +use std::path::Path; +use std::process::Command; + +use serde_json::Value; + +const REV: &str = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"; +const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("hayro-syntax", "0.7.2")]; + +pub fn run(root: &Path) -> Result<(), String> { + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(root) + .args(["metadata", "--locked", "--format-version", "1"]) + .output() + .map_err(|e| format!("cargo metadata: {e}"))?; + if !output.status.success() { + return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr))); + } + let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?; + validate(&metadata)?; + println!("PDF backend: pinned parser only; no Hayro interpreter, rasterizer or bundled CMaps"); + Ok(()) +} + +fn validate(metadata: &Value) -> Result<(), String> { + let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?; + let nodes = metadata.pointer("/resolve/nodes").and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?; + let source = format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}"); + for (name, version) in PACKAGES { + let matches: Vec<_> = packages.iter().filter(|p| p["name"] == *name).collect(); + if matches.len() != 1 || matches[0]["version"] != *version || matches[0]["source"] != source { + return Err(format!("{name}: expected exactly {version} from {source}")); + } + } + for (name, allowed) in [("craft-pdf", "read"), ("hayro-syntax", "std")] { + let package = packages.iter().find(|p| p["name"] == name).ok_or_else(|| format!("missing {name}"))?; + let node = nodes.iter().find(|n| n["id"] == package["id"]).ok_or_else(|| format!("{name}: missing resolved node"))?; + let features = node["features"].as_array().ok_or_else(|| format!("{name}: missing resolved features"))?; + if features.len() != 1 || features.first().and_then(Value::as_str) != Some(allowed) { + return Err(format!("{name}: expected only the {allowed} feature")); + } + } + for package in packages { + let name = package["name"].as_str().unwrap_or(""); + let active = nodes.iter().any(|node| node["id"] == package["id"]); + if active && matches!(name, "hayro" | "hayro-interpret" | "hayro-cmap" | "krilla") { + return Err(format!("{name}: EffectCraft must use craft-pdf with only the read feature")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn metadata() -> Value { + let packages: Vec<_> = PACKAGES.iter().map(|(name, version)| { + json!({"id": name, "name": name, "version": version, "source": format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")}) + }).collect(); + json!({"packages": packages, "resolve": {"nodes": [ + {"id": "hayro-syntax", "features": ["std"]}, {"id": "craft-pdf", "features": ["read"]} + ]}}) + } + + #[test] + fn accepts_the_pinned_parser_only_graph() { + assert!(validate(&metadata()).is_ok()); + } + + #[test] + fn rejects_unpinned_duplicate_and_missing_parsers() { + let mut m = metadata(); + m["packages"][0]["source"] = json!("registry"); + assert!(validate(&m).is_err()); + let mut m = metadata(); + let duplicate = m["packages"][0].clone(); + m["packages"].as_array_mut().unwrap().push(duplicate); + assert!(validate(&m).is_err()); + m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax"); + assert!(validate(&m).is_err()); + } + + #[test] + fn rejects_shared_reader_source_drift() { + let mut m = metadata(); + m["packages"][0]["source"] = Value::Null; + assert!(validate(&m).is_err()); + } + + #[test] + fn rejects_extra_features_and_missing_active_nodes() { + for (index, feature) in [(0, "images"), (1, "write"), (1, "raster")] { + let mut m = metadata(); + m["resolve"]["nodes"][index]["features"].as_array_mut().unwrap().push(json!(feature)); + assert!(validate(&m).is_err()); + } + let mut m = metadata(); + m["resolve"]["nodes"].as_array_mut().unwrap().pop(); + assert!(validate(&m).is_err()); + } + + #[test] + fn rejects_backend_asset_features_and_missing_resolution() { + for name in ["hayro", "hayro-interpret", "hayro-cmap", "krilla"] { + let mut m = metadata(); + m["packages"].as_array_mut().unwrap().push(json!({"id": name, "name": name})); + m["resolve"]["nodes"].as_array_mut().unwrap().push(json!({"id": name})); + assert!(validate(&m).is_err()); + } + let mut m = metadata(); + m["resolve"] = Value::Null; + assert!(validate(&m).is_err()); + } +} From 6c45fec27dca21cf9f5fdb729999c3c2051af428 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 21:17:07 -0400 Subject: [PATCH 47/56] ci: add nix check workflow --- .github/workflows/nix.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 00000000..18392587 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,33 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + - name: nix flake check --no-build + run: | + if [ -f flake.nix ]; then + nix flake check --no-build --all-systems + else + echo "No flake.nix present in this repository; skipping check." + fi From 261c54d8546905741ebd0c9ea44a96ab52e12706 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 23:57:34 -0400 Subject: [PATCH 48/56] ci: switch to shared nix workflow from craft-common-actions --- .github/workflows/nix.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587..01cb9cd6 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main From 0e2f43b824c1e4d942ead290b7832e4d72514ba6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 16:42:34 +0700 Subject: [PATCH 49/56] perf(project): keep the Size cell as After Effects shows it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the byte-count tooltip, the "—" marker and their strings and UI test, and the bench wrapper script: they are outside this PR's scope and After Effects leaves the Size cell blank when the size is unknown. The size sort and its tests are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ui.rs | 14 ---- crates/ui-egui/src/panels/project.rs | 16 +--- crates/ui-egui/tests/ui_project_rows.rs | 102 ------------------------ scripts/bench-project-size-sort.sh | 4 - 4 files changed, 2 insertions(+), 134 deletions(-) delete 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 d61eff44..3a4dd4af 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -252,8 +252,6 @@ const JAPANESE: &[(&str, &str)] = &[ ("Focus Distance", "フォーカス距離"), ("Follow Feature Rotation and Scale", "特徴の回転とスケールに追従"), ("Footage: {}", "フッテージ:{}"), - ("File size: {} bytes", "ファイルサイズ: {} バイト"), - ("File size unavailable", "ファイルサイズを取得できません"), ("Format: {}", "形式:{}"), ("Frame Aspect Ratio: {}", "フレームアスペクト比:{}"), ("Frame Blending: {}", "フレームブレンド:{}"), @@ -798,8 +796,6 @@ 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: {}", "帧混合:{}"), @@ -1532,8 +1528,6 @@ 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: {}", "影格混合:{}"), @@ -2269,8 +2263,6 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Focus Distance", "Відстань фокусування"), ("Follow Feature Rotation and Scale", "Враховувати обертання та масштаб ознаки"), ("Footage: {}", "Матеріал: {}"), - ("File size: {} bytes", "Розмір файлу: {} байт"), - ("File size unavailable", "Розмір файлу недоступний"), ("Format: {}", "Формат: {}"), ("Frame Aspect Ratio: {}", "Пропорції кадру: {}"), ("Frame Blending: {}", "Змішування кадрів: {}"), @@ -2823,8 +2815,6 @@ 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: {}"), @@ -3275,8 +3265,6 @@ 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ů: {}"), @@ -3832,8 +3820,6 @@ 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 2cb6b8c7..76309a5d 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -766,19 +766,7 @@ 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" => { - 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 - } + "size" => file_size(it).map(fmt_size).unwrap_or_default(), "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(), @@ -1224,7 +1212,7 @@ mod tests { } #[test] - #[ignore = "filesystem benchmark; run scripts/bench-project-size-sort.sh"] + #[ignore = "filesystem benchmark; run with --ignored --nocapture"] fn size_sort_benchmark() { let files = SizeFiles::new(); let mut project = Project::default(); diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 949310f5..27ce5e69 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -84,108 +84,6 @@ 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 deleted file mode 100755 index b85e495d..00000000 --- a/scripts/bench-project-size-sort.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/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 bafc7e620d58e43e62b4b673eb7592a66f9fff06 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 17:05:48 +0700 Subject: [PATCH 50/56] Docs: parity docs list Composition - Retain Layer Sizes for PDF / Illustrator (#1536) Co-Authored-By: Claude Opus 5.5 --- docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9a98b215..9e2249e9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -43,7 +43,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | TIFF | R/W | yes | yes (sequence) | good | | OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) | | Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec | -| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS | +| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes; Composition and Composition - Retain Layer Sizes, #1536) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS | | SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't | | GIF | R/W | yes | yes (animated) | good | | BMP | R | yes | — | good | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 89464bcd..eac744b6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -426,7 +426,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Layers | 88% | 4.8 | motion blur of collapsed precomps (their own motion and the parent camera's) and of animated layer content (lock-aware layer commands, delete keeping children in place, step-by-step Arrange, Transform keeping depth and separated dimensions and links kept on duplicate / paste landed in M3.11; frame blending, collapse transformations and slip edit in M4.3–M4.4) | | Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control; M13.4: HEVC export (MP4 `hvc1`, `effectcraft-hevcenc`: Main / Main 10, IDR + P slices, quarter-pel motion, deblocking, bit-exact with ffmpeg) and AV1 export (MP4 `av01` and WebM, `effectcraft-av1enc`: 8/10-bit key + inter frames, quarter-pel motion, loop filter, bit-exact with libdav1d) with profile, level, bitrate / constant-quality and key-frame interval options; Opus SILK (NB/MB/WB, mid/side stereo) and hybrid (SWB/FB) modes chosen by bitrate and Audio/Voice tuning, with the Opus bitrate in the Output Module. HEVC/AV1 import decodes through FilmCraft (MP4/MOV/MKV/WebM); WebM VP9 alpha (BlockAdditions) imports as straight alpha. Left: HEVC/AV1 have no B-frames, multi-reference, SAO/CDEF/restoration or alpha (compression below mature encoders); Opus has no FEC/DTX and only 20 ms frames; Photoshop sequence output, overflow for movies only checks at file creation | | Audio | 85% | 0.5 | audio to keyframes | -| Import | 97% | 0.5 | JPX / JBIG2 images and predefined non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.12: mesh shadings — free-form / lattice Gouraud triangles, Coons and tensor patches — and function-based shadings, CCITT G3 / G4 images, knockout / isolated transparency groups with group alpha and soft masks on the group's result, `/W2` vertical metrics, EPS text with embedded Type 1 / CFF or bundled fonts, and Create Shapes from Vector Layer keeping images as parented footage layers in paint order; M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) | +| Import | 97% | 0.5 | JPX / JBIG2 images and predefined non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.12: mesh shadings — free-form / lattice Gouraud triangles, Coons and tensor patches — and function-based shadings, CCITT G3 / G4 images, knockout / isolated transparency groups with group alpha and soft masks on the group's result, `/W2` vertical metrics, EPS text with embedded Type 1 / CFF or bundled fonts, and Create Shapes from Vector Layer keeping images as parented footage layers in paint order; M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier; PDF / AI Composition - Retain Layer Sizes crops each layer to its artwork in place, #1536) | | Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | | Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates; nested comp markers on the precomp layer bar (Preserve frame rate / resolution when nested and nested comps showing nothing past their end landed in M4.9–M4.10; Pre-compose keeping the comp's settings and New Comp from Selection's dialog in M3.12; CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | From 86dff3ce780aff4b0297b0ca9c8d29a9dc187ce1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 17:13:40 +0700 Subject: [PATCH 51/56] Footage: relinking a moved project keeps Windows separators and UNC shares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project_dir rebuilt the folder with `/` and dropped a UNC share's leading `\`, so a project opened from \server\share\Job looked for its moved footage under the relative path server/share/Job and never found it. It now keeps the path's own separator and its leading `/` or `\`. The relink tests built mixed-separator paths (`…\Job/p.ecproj`) and so failed on Windows; they now use native paths and check the separator is kept. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/relink_moved.rs | 27 +++++++++++++--------- crates/engine/src/footage_check.rs | 4 ++-- crates/host/tests/relink.rs | 4 ++-- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/commands/relink_moved.rs b/crates/engine/src/commands/relink_moved.rs index 4f70dd6e..ef64a423 100644 --- a/crates/engine/src/commands/relink_moved.rs +++ b/crates/engine/src/commands/relink_moved.rs @@ -126,17 +126,18 @@ pub(crate) fn find_moved(services: &dyn Services, project_dir: &str, f: &Footage }) } -/// The folder a project file is in. +/// The folder a project file is in, with the path's own separator and its leading `/` or UNC `\\`. pub(crate) fn project_dir(project_path: &str) -> Option { let p = parts(project_path); - if p.len() < 2 { - return None; - } - let mut dir = join(if project_path.starts_with('/') { "/" } else { "" }, p.get(..p.len() - 1)?); - if !project_path.starts_with('/') { - dir = dir.trim_start_matches(['/', '\\']).to_string(); - } - Some(dir) + let dir = p.get(..p.len().checked_sub(1)?).filter(|d| !d.is_empty())?; + let root = if project_path.starts_with("\\\\") { + "\\\\" + } else if project_path.starts_with('/') { + "/" + } else { + "" + }; + Some(format!("{root}{}", dir.join(&sep_of(project_path).to_string()))) } /// Relink `found` in one undo step and say how many. @@ -262,6 +263,10 @@ mod tests { ); assert_eq!(project_dir("/x/y/p.ecproj").as_deref(), Some("/x/y")); assert_eq!(project_dir("p.ecproj"), None); + // Windows paths keep their separator, and a UNC share its `\\`. + assert_eq!(project_dir("C:\\Work\\Job\\p.ecproj").as_deref(), Some("C:\\Work\\Job")); + assert_eq!(project_dir("\\\\server\\share\\Job\\p.ecproj").as_deref(), Some("\\\\server\\share\\Job")); + assert_eq!(moved_candidates("\\\\server\\share\\Job", "D:\\Old\\Job\\shots\\a.png")[2], "\\\\server\\share\\Job\\shots\\a.png"); // Hostile input doesn't panic. for (a, b) in [("", ""), ("/", "/"), ("x", ""), ("\\\\", "//"), ("/a", "/a/b/c")] { let _ = moved_root(a, b); @@ -302,11 +307,11 @@ mod tests { let b = add(&mut s, "/gone/proj/shots/b.mov"); let m = add(&mut s, "/gone/proj/audio/m.wav"); let other = add(&mut s, "/elsewhere/c.mov"); - let target = new.join("shots/a.mov").to_string_lossy().to_string(); + let target = new.join("shots").join("a.mov").to_string_lossy().to_string(); let r = s.execute("footage.relink", json!({"item": a.0, "path": target})).unwrap(); assert_eq!(r["relinked"], json!([a.0, b.0, m.0]), "{r}"); assert_eq!(r["stillMissing"], 1); - assert_eq!(path_of(&s, m), new.join("audio/m.wav").to_string_lossy()); + assert_eq!(path_of(&s, m), new.join("audio").join("m.wav").to_string_lossy()); assert_eq!(path_of(&s, other), "/elsewhere/c.mov"); // One undo step brings every path back. s.execute("edit.undo", json!({})).unwrap(); diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index de14b06c..9524a208 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -254,7 +254,7 @@ mod tests { std::fs::write(d.join("Job/shots/a.png"), b"x").unwrap(); // Saved on another machine: the footage sat next to the project in /Users/x/Job. let mut s = session_with(&["/Users/x/Job/shots/a.png", "/Users/x/elsewhere/b.png"]); - let path = d.join("Job/p.ecproj"); + let path = d.join("Job").join("p.ecproj"); s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); let mut o = Session::default(); o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); @@ -269,7 +269,7 @@ mod tests { _ => None, }) .collect(); - assert!(paths.contains(&d.join("Job/shots/a.png").to_string_lossy().to_string()), "{paths:?}"); + assert!(paths.contains(&d.join("Job").join("shots").join("a.png").to_string_lossy().to_string()), "{paths:?}"); assert!(!o.is_dirty(), "finding the files is not an edit"); let _ = std::fs::remove_dir_all(d); } diff --git a/crates/host/tests/relink.rs b/crates/host/tests/relink.rs index 5bb31b4e..f66d8b80 100644 --- a/crates/host/tests/relink.rs +++ b/crates/host/tests/relink.rs @@ -38,14 +38,14 @@ fn replacing_a_missing_file_relinks_the_others_that_moved_with_it() { let before = footage_paths(&s); assert_eq!(before.len(), 2, "{before:?}"); // The job folder moves; both files go missing. - let new = root.join("Archive/Job"); + let new = root.join("Archive").join("Job"); std::fs::create_dir_all(root.join("Archive")).unwrap(); std::fs::rename(&old, &new).unwrap(); let r = s.execute("footage.check", json!({"wait": true})).unwrap(); assert_eq!(r["missing"], 2, "{r}"); // Locating one finds the other. let a = before.iter().find(|(_, p, _)| p.ends_with("a.png")).unwrap().0; - let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills/a.png").to_string_lossy()})).unwrap(); + let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills").join("a.png").to_string_lossy()})).unwrap(); assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}"); let after = footage_paths(&s); assert!(after.iter().all(|(_, p, missing)| p.starts_with(&*new.to_string_lossy()) && !missing), "{after:?}"); From fc38904b41ec8e6653eb2837ac2d7fccbd1f58ce Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 17:33:31 +0700 Subject: [PATCH 52/56] Liquify: saved projects show Distortion Mesh Offset too (#1194) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/migrate.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 0c0dad9d..0e037022 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -239,6 +239,8 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) { // A path typed as text, or set only by commands, that got a Choose… button (Apply Color // LUT's LUT, the OCIO files, #514). (ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(), + // An internal point that became a control (Liquify's Distortion Mesh Offset, #1194). + (ParamUi::Hidden, ParamUi::Point) => pr.ui = ParamUi::Point, // Same kind of value: take the spec's hints (units, ranges). (a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(), _ => {} @@ -514,6 +516,19 @@ mod tests { } } + /// #1194: Liquify's Distortion Mesh Offset was hidden; old projects show it, keeping its value. + #[test] + fn a_hidden_point_that_became_a_control_is_shown() { + let mut next = 1; + let (spec, mut g) = instance("ec.distort.liquify", &mut next); + let pr = g.get_mut("distortionMeshOffset").unwrap(); + pr.ui = ParamUi::Hidden; + pr.value = Value::Vec2([20.0, -5.0]); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0])); + let pr = g.get("distortionMeshOffset").unwrap(); + assert_eq!((&pr.ui, &pr.value), (&ParamUi::Point, &Value::Vec2([20.0, -5.0]))); + } + #[test] fn upgrade_moves_params_into_twirl_down_groups() { let spec = find(crate::warp_stab::ID).unwrap(); From 45eacf1b17586cdae9b91bb7d56a6f3b238bf9c3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 17:33:50 +0700 Subject: [PATCH 53/56] GPU: upload_buf keeps its own doc comment Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/context.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 0c5763a7..3c372222 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -917,14 +917,14 @@ impl GpuContext { Some(g) } - /// 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() } + /// Upload a (layer cache) buffer, reusing the texture while the same `Arc` is alive: static + /// layers upload once, not every frame. 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() { From 183fe652e43f06b65a484b58f521b3c64f29b7ae Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 17:36:11 +0700 Subject: [PATCH 54/56] pdf: an inline image just under usize::MAX bytes doesn't overflow its end offset Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/page.rs | 2 +- crates/pdf/src/tests_import.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 13e98b3c..1d84c0fb 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -515,7 +515,7 @@ impl<'a> Interp<'a> { && (p + 2 == data.len() || crate::object::is_white(data[p + 2])) }; let end = match crate::image::inline_len(self.file, &d, Some(res)) { - Some(len) => (start + len).min(data.len()), + Some(len) => start.saturating_add(len).min(data.len()), None => { let mut p = start; while p + 1 < data.len() && !is_ei(p) { diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 3e7cd570..2e103d5e 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -276,6 +276,11 @@ fn inline_image_with_absurd_dimensions_falls_back_to_ei_scan() { let doc = parse(&bytes).unwrap(); let _ = render(&doc); assert!(!doc.skipped.is_empty()); + // A length just under `usize::MAX` passes the row math; past byte 4096 of the content the + // caller's `start + len` would then overflow. + let content = format!("{}q 10 0 0 10 0 0 cm BI /W 2305843009213693440 /H 8 /CS /G /BPC 8 ID EI Q", " ".repeat(5000)); + let doc = parse(&page_pdf(&content, "", vec![])).unwrap(); + let _ = render(&doc); } #[test] From f8b587503decbf0bde625e08514b1bc3710eb228 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 17:36:55 +0700 Subject: [PATCH 55/56] pdf: rustfmt the inline-image test Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/tests_import.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 2e103d5e..ad918bf4 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -268,11 +268,7 @@ fn inline_image_with_absurd_dimensions_falls_back_to_ei_scan() { // `/W 5000000000000000000` overflows the row math (`* bpc` alone exceeds usize::MAX): // `inline_len` must return `None` (scan for `EI`) instead of overflowing; the oversized // image itself is then refused by size. - let bytes = page_pdf( - "q 10 0 0 10 0 0 cm BI /W 5000000000000000000 /H 100 /CS /G /BPC 8 ID \0 EI Q", - "", - vec![], - ); + let bytes = page_pdf("q 10 0 0 10 0 0 cm BI /W 5000000000000000000 /H 100 /CS /G /BPC 8 ID \0 EI Q", "", vec![]); let doc = parse(&bytes).unwrap(); let _ = render(&doc); assert!(!doc.skipped.is_empty()); From 6f2f98b3982da86374f1949ca37c92291b755cdc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 17:48:08 +0700 Subject: [PATCH 56/56] pdf: inline_len returns the checked product directly (clippy) Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/image.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs index 69ff911e..2c8adda6 100644 --- a/crates/pdf/src/image.rs +++ b/crates/pdf/src/image.rs @@ -85,7 +85,7 @@ pub(crate) fn inline_len(file: &File, d: &Dict, res: Option<&Dict>) -> Option