From 058da6f3b053e42161d73670300eaffbd7b93723 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 10:09:51 +0000 Subject: [PATCH 01/91] CI: use a match guard in the STL reader --- crates/model/src/stl.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858ad..34e9a7f06 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` From 037a21d5a6330a47b338daad0109f6a09419249f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 10:09:51 +0000 Subject: [PATCH 02/91] #1200: allow TGA proxies and add import regressions --- contributors/people.toml | 2 +- crates/host/tests/tga_import.rs | 48 +++++++++++++ crates/media/tests/tga.rs | 115 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 19 +++++- 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 crates/host/tests/tga_import.rs create mode 100644 crates/media/tests/tga.rs diff --git a/contributors/people.toml b/contributors/people.toml index 705feb257..0de777b11 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -66,7 +66,7 @@ sync_github_name = true display_name = "Jason Brashear" [people.nanox333] -display_name = "nanox333" +display_name = "nnaannoo" [people.Nael-Nathanael] sync_github_name = true diff --git a/crates/host/tests/tga_import.rs b/crates/host/tests/tga_import.rs new file mode 100644 index 000000000..5462f4bec --- /dev/null +++ b/crates/host/tests/tga_import.rs @@ -0,0 +1,48 @@ +//! Targa import through the same host and commands used by the desktop and CLI. + +use effectcraft_engine::render::RenderOpts; +use effectcraft_project::{FootageKind, ItemId, ItemKind}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +#[test] +fn tga_stills_and_numbered_sequences_import_and_render() { + let dir = std::env::temp_dir().join(format!("effectcraft-tga-import-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A generated 4×4, 24-bit, top-origin TGA. No binary fixtures are retained. + let write = |name: &str, bgr: [u8; 3]| { + let mut bytes = vec![0; 18]; + bytes[2] = 2; + bytes[12] = 4; + bytes[14] = 4; + bytes[16] = 24; + bytes[17] = 0x20; + bytes.extend_from_slice(&bgr.repeat(16)); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().to_string() + }; + let still = write("still.TGA", [0, 0, 255]); + let first = write("shot_0001.tga", [0, 0, 255]); + write("shot_0002.tga", [255, 0, 0]); + for (path, sequence) in [(still, false), (first, true)] { + let mut session = effectcraft_host::session(); + let imported = session.execute_checked("file.import", json!({"paths": [path], "sequence": sequence})).unwrap(); + assert_eq!(imported["errors"], json!([]), "{imported}"); + assert_eq!(imported["items"].as_array().unwrap().len(), 1); + let item = ItemId(imported["items"][0].as_u64().unwrap()); + let ItemKind::Footage(footage) = &session.project.item(item).unwrap().kind else { panic!("not footage") }; + assert_eq!(footage.kind, if sequence { FootageKind::Sequence } else { FootageKind::Still }); + assert_eq!(footage.sequence.len(), if sequence { 2 } else { 0 }); + session.execute_checked("comp.new", json!({"name": "TGA", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap(); + session.execute_checked("layer.addItem", json!({"item": item.0})).unwrap(); + let comp = session.active_comp_id().unwrap(); + let image = session.render(comp, Tick::ZERO, RenderOpts::default()); + assert_eq!(image.get(2, 2), [1.0, 0.0, 0.0, 1.0]); + if sequence { + let image = session.render(comp, FrameRate::FPS_30.tick_of(1), RenderOpts::default()); + assert_eq!(image.get(2, 2), [0.0, 0.0, 1.0, 1.0]); + } + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/media/tests/tga.rs b/crates/media/tests/tga.rs new file mode 100644 index 000000000..61aacd957 --- /dev/null +++ b/crates/media/tests/tga.rs @@ -0,0 +1,115 @@ +//! Tiny synthetic Targa files exercise import without external fixtures or codecs. + +use std::path::Path; +use std::sync::Arc; + +use effectcraft_media::{MediaPool, is_importable, probe_bytes}; +use effectcraft_project::{AlphaMode, Footage, FootageKind}; +use effectcraft_time::Tick; + +/// A classic 2×2 TGA without a footer. True-colour pixels are stored BGR(A); RLE +/// packets repeat each row's colour twice. `pixels` is always in top-down RGBA order. +fn tga(pixels: [[u8; 4]; 4], alpha: bool, rle: bool, top: bool) -> Vec { + let mut bytes = vec![0; 18]; + bytes[2] = if rle { 10 } else { 2 }; + bytes[12] = 2; + bytes[14] = 2; + bytes[16] = if alpha { 32 } else { 24 }; + bytes[17] = if top { 0x20 } else { 0 } | if alpha { 8 } else { 0 }; + let order = if top { [0, 1, 2, 3] } else { [2, 3, 0, 1] }; + for row in order.chunks_exact(2) { + if rle { + assert_eq!(pixels[row[0]], pixels[row[1]]); + bytes.push(0x81); // A two-pixel run. + } + for &i in row.iter().take(if rle { 1 } else { 2 }) { + let [r, g, b, a] = pixels[i]; + bytes.extend_from_slice(&[b, g, r]); + if alpha { + bytes.push(a); + } + } + } + bytes +} + +#[test] +fn tga_raw_and_rle_preserve_colours_orientation_and_alpha() { + for alpha in [false, true] { + for rle in [false, true] { + for top in [false, true] { + let pixels = if rle { + [[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 255], [0, 0, 255, 255]] + } else { + [[255, 0, 0, 128], [0, 255, 0, 255], [0, 0, 255, 64], [255, 255, 255, 0]] + }; + let path = if top { "/frame.TGA" } else { "/frame.tga" }; + assert!(is_importable(Path::new(path))); + let bytes: Arc<[u8]> = tga(pixels, alpha, rle, top).into(); + let footage = probe_bytes(path, bytes.clone()).unwrap(); + assert_eq!(footage.kind, FootageKind::Still); + assert_eq!((footage.width, footage.height), (2, 2)); + assert_eq!(footage.codec, "TGA"); + assert_eq!(footage.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }); + assert!(footage.has_video && !footage.has_audio); + let pool = MediaPool::new(); + pool.add_bytes(path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + for (actual, [r, g, b, a]) in image.data.iter().zip(pixels) { + let a = if alpha { f32::from(a) / 255.0 } else { 1.0 }; + let expected = [f32::from(r) / 255.0 * a, f32::from(g) / 255.0 * a, f32::from(b) / 255.0 * a, a]; + assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{actual:?} != {expected:?}"); + } + } + } + } +} + +#[test] +fn tga_sequence_decodes_each_frame_from_memory() { + let pool = MediaPool::new(); + pool.set_read_ahead(false); + let paths = ["/shot_0001.tga", "/shot_0002.tga"]; + let colours = [[255, 0, 0, 255], [0, 0, 255, 255]]; + let mut footage = None; + for (path, colour) in paths.iter().zip(colours) { + let bytes: Arc<[u8]> = tga([colour; 4], false, true, true).into(); + footage.get_or_insert_with(|| probe_bytes(path, bytes.clone()).unwrap()); + pool.add_bytes(path, bytes); + } + let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.iter().map(|s| s.to_string()).collect(), ..footage.unwrap() }; + footage.sync_sequence_duration(); + for (n, expected) in [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]].into_iter().enumerate() { + let image = pool.frame_at(&footage, footage.frame_rate.tick_of(n as i64)).unwrap(); + assert_eq!(image.get(0, 0), expected); + } +} + +#[test] +fn malformed_tga_returns_contextual_errors() { + for bytes in [vec![], vec![0; 17], vec![0; 18]] { + let error = probe_bytes("/broken.tga", bytes.into()).unwrap_err().to_string(); + assert!(error.contains("/broken.tga"), "{error}"); + } + let mut bytes = tga([[255, 0, 0, 255]; 4], true, false, true); + bytes.truncate(19); // Valid metadata, truncated pixel data. + let bytes: Arc<[u8]> = bytes.into(); + let footage = probe_bytes("/truncated.tga", bytes.clone()).unwrap(); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let error = pool.frame_at(&footage, Tick::ZERO).unwrap_err().to_string(); + assert!(error.contains("/truncated.tga"), "{error}"); +} + +#[test] +fn a_recognised_signature_takes_precedence_over_the_tga_extension() { + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(3, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let bytes: Arc<[u8]> = bytes.into_inner().into(); + let footage = probe_bytes("/actually-png.tga", bytes.clone()).unwrap(); + assert_eq!(footage.codec, "PNG"); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + assert_eq!((image.width, image.height), (3, 2)); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c30..53783859e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1361,7 +1361,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => { + ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])) + } _ => return None, }; if params.get(key).is_some() @@ -2494,6 +2496,21 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn tga_is_available_in_import_replace_and_proxy_dialogs() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + let pending = Arc::new(Mutex::new(Vec::new())); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + for command in ["file.import", "file.importMultiple", "file.replaceFootage", "file.setProxy"] { + assert_eq!(invoke(&mut app, &ctx, command, json!({})).unwrap(), json!({"dialog": command})); + let crate::FileDialog::Open { exts, .. } = pending.lock().unwrap().pop().unwrap() else { panic!("not an open dialog") }; + assert!(exts.iter().any(|ext| ext == "tga"), "{command}: {exts:?}"); + } + } + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help")); From 2ddbf20466e79b71e53dc565d1bb0b22c814e926 Mon Sep 17 00:00:00 2001 From: Moein Saharkhiz <142007707+moecr7@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:31:17 -0400 Subject: [PATCH 03/91] #1070: refresh layered composition sources on import --- crates/engine/src/commands/file.rs | 7 +++ crates/host/tests/footage_reload.rs | 77 +++++++++++++++++++++++++++++ docs/footage.md | 3 ++ 3 files changed, 87 insertions(+) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 3b43eb633..0b131ec7a 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -415,6 +415,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { } } paths = rest; + // Composition imports already made their sources, so they bypass add_footage's + // probed-footage refresh. Refresh them before any background probe job starts (#1070). + for id in &items { + if let Some(ItemKind::Footage(f)) = s.project.item(ItemId(*id)).map(|it| &it.kind) { + s.forget_decoded(f); + } + } } // Numbered stills as image sequences (the Import dialog's " Sequence" and Force // Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage. diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/footage_reload.rs index 6834fc543..faf2b09e3 100644 --- a/crates/host/tests/footage_reload.rs +++ b/crates/host/tests/footage_reload.rs @@ -213,3 +213,80 @@ fn a_new_project_importing_an_overwritten_file_shows_the_new_pixels() { let cid = s.active_comp_id().unwrap(); assert!(near(pixel(&s, cid, 32, 24), BLUE), "stale pixels from the earlier project: {:?}", pixel(&s, cid, 32, 24)); } + +fn layered_comp(s: &mut Session, path: &str, import_as: &str) -> ItemId { + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap(); + ItemId(r["comps"][0].as_u64().unwrap()) +} + +/// Composition imports create their sources before probing ordinary footage (#1070). +#[test] +fn fresh_layered_composition_import_refreshes_its_sources() { + for import_as in ["composition", "compositionLayerSizes"] { + let path = tmp(&format!("fresh-{import_as}.psd")); + let (red, blue) = (flat(RED, false), flat(BLUE, false)); + assert_eq!(red.len(), blue.len(), "the overwritten file has the same byte size"); + std::fs::write(&path, red).unwrap(); + let mut s = effectcraft_host::session(); + let old_comp = layered_comp(&mut s, &path, import_as); + assert!(near(pixel(&s, old_comp, 32, 24), RED)); + s.execute_checked("file.newProject", json!({})).unwrap(); + save(&path, &blue); + s.drain_events(); + let comp = layered_comp(&mut s, &path, import_as); + assert_eq!(comp, old_comp, "new projects reuse their item ids"); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "layered import showed {:?}", pixel(&s, comp, 32, 24)); + std::fs::remove_file(path).unwrap(); + } +} + +#[test] +fn layered_reimport_refreshes_existing_layers_and_keeps_unrelated_decoded_files() { + let path = tmp("layered-reimport.psd"); + let other = tmp("layered-unchanged.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + std::fs::write(&other, flat(GREEN, false)).unwrap(); + let mut s = effectcraft_host::session(); + let comp = layered_comp(&mut s, &path, "compositionLayerSizes"); + let r = s.execute_checked("file.import", json!({"paths": [&other]})).unwrap(); + let other_item = ItemId(r["items"][0].as_u64().unwrap()); + let other_footage = footage(&s, other_item); + let cached = s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap(); + assert!(near(pixel(&s, comp, 32, 24), RED)); + let before = s.project.clone(); + let undo = s.history.undo.len(); + save(&path, &flat(BLUE, false)); + s.drain_events(); + layered_comp(&mut s, &path, "compositionLayerSizes"); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "existing layers see the refreshed source"); + assert!(std::sync::Arc::ptr_eq(&cached, &s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap())); + assert_eq!(s.history.undo.len(), undo + 1, "composition import stays one undo step"); + s.undo(); + assert!(std::sync::Arc::ptr_eq(&s.project, &before)); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "undo does not undo an external file write"); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(other).unwrap(); +} + +#[test] +fn background_import_refreshes_layered_sources_before_the_probe_job_finishes() { + let path = tmp("layered-background.psd"); + let other = tmp("layered-background.png"); + std::fs::write(&path, flat(RED, false)).unwrap(); + image::RgbaImage::from_pixel(64, 48, image::Rgba([0, 255, 0, 255])).save(&other).unwrap(); + let mut s = effectcraft_host::session(); + let comp = layered_comp(&mut s, &path, "compositionLayerSizes"); + assert!(near(pixel(&s, comp, 32, 24), RED)); + save(&path, &flat(BLUE, false)); + s.drain_events(); + let r = s.execute_checked("file.import", json!({"paths": [&path, &other], "importAs": "compositionLayerSizes", "background": true})).unwrap(); + assert_eq!(r["started"], true, "ordinary footage is probed in a background job"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "composition sources are refreshed before the job is applied"); + s.wait_jobs(); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, s.active_comp_id().unwrap(), 32, 24), BLUE)); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(other).unwrap(); +} diff --git a/docs/footage.md b/docs/footage.md index 4a5a498c7..84d4c49e6 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -107,6 +107,9 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la ## Replacing and reloading +Importing a layered file as a composition refreshes the decoded sources used by its layers, +including files overwritten at the same path and byte size. + - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, "sequence": false}` for one frame). From dbc595b0d6cd37db886fc0b34057c8360b6c4f1a Mon Sep 17 00:00:00 2001 From: Jannik Wappler <167129173+RavoxX@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:06 +0300 Subject: [PATCH 04/91] #1220: Hide layer bounds during path editing --- crates/ui-egui/src/panels/viewer.rs | 8 +++ crates/ui-egui/tests/ui_viewer.rs | 97 +++++++++++++++++++++++++++++ docs/gaps.md | 6 +- docs/ui-parity.md | 9 ++- 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a881..6ec3be36c 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1028,6 +1028,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + // Path editing keeps vertices and tangents visible without the layer's transform + // box competing with them (#1220). The path's own free-transform box is separate. + let editing_path = paths.iter().any(|p| { + p.layer == l.id && (app.ui.tool.is_pen() || (app.ui.tool == Tool::Selection && sel_vertices.iter().any(|v| v.layer == l.id && v.mask == p.uid))) + }); + if editing_path { + continue; + } let Some((m, q, _)) = layer_quad(&ectx, l) else { // A 3D layer reaching past the camera: the part of its box in front of it. let outline = layer_outline(&ectx, l); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1d..d37180a8e 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -677,6 +677,103 @@ fn fill_options_change_the_selected_shape_layer() { } } +/// #1220: path tools hide the layer transform box, while path vertices remain editable. +#[test] +fn path_editing_hides_layer_bounds_and_keeps_corner_vertices_editable() { + for mask in [false, true] { + let mut h = harness(); + let vertices = [[200.0, 100.0], [400.0, 100.0], [400.0, 260.0], [200.0, 260.0]]; + let (layer, uid, kind) = if mask { + let layer = layer_id(&h, "Plate"); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + let r = h + .state_mut() + .session + .execute("mask.new", json!({"layer": layer, "vertices": vertices, "outTangents": [[40, 0], [0, 0], [0, 0], [0, 0]], "closed": true})) + .unwrap(); + (layer, r["mask"].as_u64().unwrap(), "mask") + } else { + let r = h.state_mut().session.execute("shape.newPath", json!({"vertices": vertices, "outTangents": [[40, 0], [0, 0], [0, 0], [0, 0]], "closed": true, "fill": false, "stroke": "#ffffff", "strokeWidth": 4})).unwrap(); + (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap(), "shapePath") + }; + h.state_mut().session.state.selected_vertices.clear(); + h.run_steps(2); + let handle = format!("viewer.handle.{layer}.0"); + assert!(h.state().auto.find(&handle).is_some(), "ordinary layer selection has transform handles (mask={mask})"); + for tool in [Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather] { + h.state_mut().ui.tool = tool; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_none(), "{tool:?}: hide the layer box"); + assert!(h.state().auto.find(&format!("viewer.{kind}.{uid}.vertex.0")).is_some(), "{tool:?}: keep the path point"); + } + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + let vertex = format!("viewer.{kind}.{uid}.vertex.0"); + let from = rect(&h, &vertex).center(); + click(&mut h, from); + h.run_steps(2); // The automation registry also keeps the preceding frame's elements. + assert!(h.state().auto.find(&handle).is_none(), "selecting a vertex starts path editing (mask={mask})"); + let position = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().value.clone(); + let path = |h: &Harness<'_, EffectcraftApp>| { + h.state() + .session + .active_comp() + .unwrap() + .layer(LayerId(layer)) + .unwrap() + .props + .find_group(uid) + .unwrap() + .get("path") + .unwrap() + .value + .as_path() + .unwrap() + .clone() + }; + let before = path(&h); + drag(&mut h, from, from + vec2(15.0, 12.0)); + assert_ne!(path(&h).vertices[0], before.vertices[0], "the corner vertex can still be moved"); + assert_eq!(path(&h).vertices[1..], before.vertices[1..]); + assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().value, position); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(path(&h), before, "moving the vertex undoes in one step"); + let z = rect(&h, "viewer.comp").width() / 640.0; + let tangent = rect(&h, &vertex).center() + vec2(40.0 * z, 0.0); + drag(&mut h, tangent, tangent + vec2(0.0, 20.0)); + assert_ne!(path(&h).out_tangents[0], before.out_tangents[0], "the Bézier handle is still editable"); + assert_eq!(path(&h).vertices, before.vertices, "a tangent drag does not move the path points"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(path(&h), before, "the tangent drag undoes in one step"); + h.state_mut().ui.tool = Tool::Rotate; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "a layer transform tool restores the box"); + h.state_mut().ui.tool = Tool::Selection; + h.state_mut().session.state.selected_vertices.clear(); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "ending vertex selection restores the box"); + } +} + +#[test] +fn path_tools_keep_bounds_on_layers_without_visible_paths() { + let mut h = harness(); + let layer = layer_id(&h, "Box"); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let handle = format!("viewer.handle.{layer}.0"); + assert!(h.state().auto.find(&handle).is_some(), "a layer without a path keeps its box"); + h.state_mut().session.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [80, 0], [80, 80]], "closed": true})).unwrap(); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_none()); + h.state_mut().ui.viewer.show_masks = false; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "hidden paths do not suppress the layer controls"); +} + #[test] fn shape_pen_draws_a_closed_shape_layer() { let mut h = harness(); diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..0c5e30949 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -114,6 +114,9 @@ The most important missing piece: it turns every other estimate here into a meas - Run the headless snapshot and control-channel checks on Linux (X11 and Wayland) and Windows, not only macOS. Interactions that only fail with real input (docking drags, viewer pan, drag-and-drop import, inline rename) need scripted input tests. +- Path editing (#1220): selected shape and mask paths suppress the layer transform box while + their points are edited. `ui_viewer` regressions cover Pen-family tools, Selection vertex + dragging and undo, restoration of layer controls, and layers without visible paths. - Regression evidence for [#66](https://github.com/storytold/effectcraft/issues/66): scripted pointer tests in `ui_viewer` and `ui_text_edit`, checked headlessly on Linux, cover viewer clicks, marquee selection and text picking when layers are hidden or excluded by solo. They also check @@ -322,6 +325,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---| +| 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 | | 2026-10-10 | minor | Alpha gate checked: no alpha blockers | | 2026-10-12 | minor | Gap 21: percentage guides and effect colour labels added (#1095) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668da..366d8f770 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -33,6 +33,12 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | +Path editing (#1220): the Composition viewer suppresses a selected layer’s transform box while +using a Pen-family tool on a visible path, or while selecting its vertices with the Selection +tool. Path points, Bézier handles and the path’s free-transform controls remain available. +Layer transform tools and ordinary layer selection restore the box. Headless `ui_viewer` tests +cover shape paths and masks, corner-vertex dragging and undo, and layers without visible paths. + ## Estimate **≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted @@ -44,6 +50,7 @@ Effects running; most of the rest parallelises. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | patch | Layer bounds are suppressed during path-point editing (#1220); shape/mask interaction regressions | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated | | 2026-10-10 | major | First UI parity checklist | From 2720ae5fe29d721622502e34eb0504b05a46d726 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 23:40:32 +1100 Subject: [PATCH 05/91] Wave Warp: cover the Top/Bottom edge pinning paths end to end (#1181) The pinning regression only exercised All Edges (and Left Edge in the CPU unit test), so the reported 'does not work with Top/Bottom Edge' had no coverage to catch or clear it. This holds the pinned first/last row in a CPU test at Direction 90, and adds CPU/GPU parity cases for Top (4), Bottom (6), diagonal directions, and single-edge pinning on shape layers. CPU pinning verified correct on main: the guards in dst_wave_pin/wave_pin agree; the gap was that neither edge path was tested. --- crates/effects/src/distort.rs | 14 +++++++++++++- crates/gpu/src/tests_fx_distort.rs | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index a68de1094..195ac8985 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -556,8 +556,20 @@ mod tests { let (a, b) = (o.img.get(pad, y + pad), im.get(0, y)); assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}"); } - let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im); + let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone()); assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b))); + // #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row, + // Bottom Edge (6) the last, and the rest of the layer still moves. + for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] { + let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)]; + let o = run("ec.distort.wavewarp", &set, im.clone()); + let pad = ((o.img.width - im.width) / 2) as i64; + for x in 0..40 { + let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row)); + assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}"); + } + assert!(o.img.data.iter().zip(im.data.iter()).any(|(a, b)| differs(*a, *b)), "pin {edge_row}: layer must still distort"); + } } #[test] diff --git a/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs index e0e89fb67..19cdcd605 100644 --- a/crates/gpu/src/tests_fx_distort.rs +++ b/crates/gpu/src/tests_fx_distort.rs @@ -28,6 +28,11 @@ fn twirl_bulge_ripple_wave_warp() { effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]); + // #1181: the single-edge pinning paths, with the default vertical displacement direction. + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(25.0)), ("pinning", e(4))]); + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(19.0)), ("pinning", e(6)), ("waveType", e(1))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(4)), ("speed", n(2.0))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(6)), ("speed", n(2.0))]); } #[test] @@ -79,6 +84,9 @@ fn shape_case(id: &str, vals: &[(&str, Value)]) { fn edge_pinning_on_a_shape_layer() { shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]); + // #1181: single-edge pinning over the layer rect, not just All Edges. + shape_case("ec.distort.wavewarp", &[("pinning", e(4)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); + shape_case("ec.distort.wavewarp", &[("pinning", e(6)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); shape_case( "ec.distort.bulge", &[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))], From 48738093b571322646247cfc91921aff804fce95 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:02:12 +1100 Subject: [PATCH 06/91] Shapes: Merge Paths keeps paint that belongs to a group above it (#1174) A Merge Paths item appended to a shape layer's Contents cleared every draw collected so far, including fills and strokes inside groups above it, so the layer's shapes vanished from the render. AE's rule is per group: only paint items in the merge's own group above it are dropped. Draws now record which contents level created them; the merge drops only its own group's paint, and the group-bubbled draws keep rendering. Adds a regression test (Merge Shapes and Add modes) that renders nothing without the fix. --- crates/render/src/shapes.rs | 13 +++++++++++-- crates/render/src/shapes/tests.rs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index aa46fa501..d5e1d5b85 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -57,6 +57,10 @@ struct Draw { blend: BlendMode, /// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D). surfaces: SurfaceColors, + /// Paint item of the contents group this draw was collected in (as opposed to one bubbled up + /// from a nested group). Merge Paths drops only these paints above it (#1174); a group's own + /// fill or stroke keeps rendering inside the group. + local: bool, } /// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`] @@ -234,6 +238,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -247,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -270,6 +276,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -328,8 +335,9 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let Some(inner) = g.sub("contents") else { continue }; @@ -341,6 +349,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec 0.99 && c[1] < 0.01 && c[3] > 0.99, "mode {mode}: group rectangle must still render, got {c:?}"); + assert!((coverage(&img) - 40.0 * 40.0).abs() < 20.0, "mode {mode}: {}", coverage(&img)); + } +} + #[test] fn merge_subtract_and_intersect() { let sub = |mode: u32| { From 5270eab095a04347eed080db7a50f0e63bd75027 Mon Sep 17 00:00:00 2001 From: Ian-Chege Date: Tue, 13 Oct 2026 19:15:30 +0300 Subject: [PATCH 07/91] Masks: Mask Expansion past a curve's radius grows a solid mask instead of a ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expansion was a fill plus a stroke 2·|expansion| wide, blended per pixel. Once the expansion passed the shape's curvature radius the stroke's inner edge inverted: a 20 px circle expanded by 60 left a ring with the layer showing through the middle, +600 cut only the original dot, and negative values left islands. The outline is now offset with Offset Paths' geometry (round joins) and filled. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 2 + crates/render/src/masks.rs | 20 ++---- crates/render/src/tests_mask_expansion.rs | 82 +++++++++++++++++++++++ 3 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 crates/render/src/tests_mask_expansion.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..655009bb4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2390,6 +2390,8 @@ mod tests_frame_blend; #[cfg(test)] mod tests_mask_blur; #[cfg(test)] +mod tests_mask_expansion; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 3f8d7542b..bb91ef71d 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -3,7 +3,7 @@ use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; -use effectcraft_path::{FillRule, StrokeStyle}; +use effectcraft_path::{FillRule, Join}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; use effectcraft_raster::{Mask, box_blur, gaussian_blur}; use rayon::prelude::*; @@ -72,21 +72,15 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, let variable = !sp.feather.is_empty(); let mut cov = if variable { effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + } else if exp.abs() > 0.01 { + // Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right + // when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a + // ring around an uncovered middle (growing) or islands (shrinking). + let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0); + effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero) } else { effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) }; - if exp.abs() > 0.01 && !variable { - let ring = effectcraft_path::stroke_coverage( - std::slice::from_ref(&path), - &StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() }, - &m, - w, - h, - ); - cov.data.par_iter_mut().zip(ring.data.par_iter()).for_each(|(c, r)| { - *c = if exp > 0.0 { (*c + r - *c * r).min(1.0) } else { (*c * (1.0 - r)).max(0.0) }; - }); - } let feather = ctx.v2(layer, g, "feather", [0.0; 2]); if feather[0] > 0.0 || feather[1] > 0.0 { let img = cov.to_image(); diff --git a/crates/render/src/tests_mask_expansion.rs b/crates/render/src/tests_mask_expansion.rs new file mode 100644 index 000000000..700c779aa --- /dev/null +++ b/crates/render/src/tests_mask_expansion.rs @@ -0,0 +1,82 @@ +//! Mask Expansion grows or shrinks the mask outline by its distance, however large: past the +//! shape's curvature radius it must still cover everything within the distance of the path, +//! never leave a ring around an uncovered middle. + +use effectcraft_color::Label; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +const W: u32 = 400; +const H: u32 = 300; + +/// A white 400×300 solid with one mask of `shape` in `mode` expanded by `expansion` pixels. +fn project(shape: ShapePath, mode: MaskMode, expansion: f64) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: W, height: H, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (W, H), None); + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", shape, mode, [255, 0, 0]); + p.next_id = next; + m.get_mut("expansion").unwrap().value = Value::Scalar(expansion); + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +/// Alpha at each of `points` for a Subtract mask on a 40 px circle at the centre (the hole). +fn hole(expansion: f64, points: &[(i64, i64)]) -> Vec { + let (p, cid) = project(ShapePath::ellipse([200.0, 150.0], 40.0, 40.0), MaskMode::Subtract, expansion); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + points.iter().map(|&(x, y)| img.get(x, y)[3]).collect() +} + +#[test] +fn no_expansion_cuts_the_circle() { + let a = hole(0.0, &[(200, 150), (215, 150), (225, 150)]); + assert!(a[0] < 0.01 && a[1] < 0.01, "inside the 20 px radius is cut: {a:?}"); + assert!(a[2] > 0.99, "outside it is kept: {a:?}"); +} + +#[test] +fn expansion_past_the_radius_grows_a_solid_hole() { + // +60 on a 20 px radius: a solid hole of radius 80. The fill + stroke blend left a ring + // from 40 to 80 px with the layer showing at 21–39 px. + let a = hole(60.0, &[(200, 150), (230, 150), (200, 120), (275, 150), (285, 150)]); + assert!(a[..4].iter().all(|&v| v < 0.01), "everything within 80 px is cut: {a:?}"); + assert!(a[4] > 0.99, "beyond 80 px is kept: {a:?}"); +} + +#[test] +fn huge_expansion_cuts_the_whole_frame() { + // +600 reaches every pixel; the blend left only the 20 px circle cut. + let a = hole(600.0, &[(200, 150), (230, 150), (0, 0), (399, 299), (399, 0)]); + assert!(a.iter().all(|&v| v < 0.01), "{a:?}"); +} + +#[test] +fn negative_expansion_shrinks_then_removes_the_hole() { + let a = hole(-15.0, &[(200, 150), (210, 150)]); + assert!(a[0] < 0.01, "a 5 px hole remains: {a:?}"); + assert!(a[1] > 0.99, "shrunk away from 10 px out: {a:?}"); + let a = hole(-25.0, &[(200, 150), (190, 150)]); + assert!(a.iter().all(|&v| v > 0.99), "shrunk past its radius, nothing is cut: {a:?}"); +} + +#[test] +fn expanded_rect_add_mask_has_round_corners() { + // A 100 px square mask (Add) grown by 30: the sides move out 30 px, the corners round off. + let (p, cid) = project(ShapePath::rect([200.0, 150.0], 100.0, 100.0), MaskMode::Add, 30.0); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + assert!(img.get(275, 150)[3] > 0.99, "25 px past the right side is inside"); + assert!(img.get(285, 150)[3] < 0.01, "35 px past it is outside"); + // 25 px diagonally out from the corner (250,100) is within 30 px: inside. + assert!(img.get(267, 82)[3] > 0.99); + // The square's bounding corner (280,70) is 42 px from the corner: a miter would cover it. + assert!(img.get(277, 73)[3] < 0.01); +} From d309d6f1d0d59362eb6213f4164ef777a3f0a510 Mon Sep 17 00:00:00 2001 From: Lloyd Nicholson Date: Tue, 13 Oct 2026 18:53:31 +0200 Subject: [PATCH 08/91] Effects & Presets: drag animation presets onto a layer Preset rows (text, built-in and user .ecpreset) were click-only, so dragging one onto a layer did nothing. They now start a DragPayload::Preset drag with a ghost, and the Timeline, Composition viewer and Effect Controls apply the preset to the layer it is dropped on, as they already do for effects and as After Effects does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 10 ++-- crates/ui-egui/src/panels/effects_presets.rs | 41 +++++++-------- crates/ui-egui/src/panels/mod.rs | 52 ++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 16 ++++-- crates/ui-egui/src/panels/viewer_drop.rs | 19 +++++-- 5 files changed, 107 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..4d3bf833e 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1394,14 +1394,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(panel_clip); let content_h = y + scroll.offset - body.min.y; scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); - // Drop effects here. + // Drop effects and animation presets here. if let Some(payload) = egui::DragAndDrop::payload::(&ctx) && ui.rect_contains_pointer(rect) - && let crate::panels::DragPayload::Effect(e) = payload.as_ref() + && let Some((id, params)) = match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)), + _ => None, + } { p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); if ctx.input(|i| i.pointer.any_released()) { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [layer.id.0]}))); + actions.push((id.into(), params)); egui::DragAndDrop::clear_payload(&ctx); } } diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd85..a9410d98e 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -2,15 +2,15 @@ //! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an //! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition //! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the -//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies -//! it there. +//! selected layers; dragging an effect or an animation preset onto a layer (timeline, viewer, +//! Effect Controls) applies it there. use crate::i18n::tr; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; use crate::icons::{self, Icon}; -use crate::panels::DragPayload; +use crate::panels::{DragPayload, PresetRef}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -107,10 +107,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: & enum Apply { Effect(String), - TextPreset(String), - /// A built-in animation preset (Transitions…) by id. - Builtin(String), - PresetFile(String), + /// A text, built-in (Transitions…) or user file animation preset. + Preset(PresetRef), } /// A folder row; returns whether it is open. @@ -217,7 +215,7 @@ fn effect_row( } } -/// A preset row (no drag; double-click applies). +/// A preset row: double-click applies to the selected layers; drag it onto a layer to apply it there. #[allow(clippy::too_many_arguments)] fn preset_row( app: &mut EffectcraftApp, @@ -228,7 +226,7 @@ fn preset_row( name: &str, auto_id: &str, query: &str, - on_apply: Apply, + preset: PresetRef, apply: &mut Option, ) { let t = app.tokens; @@ -237,15 +235,18 @@ fn preset_row( if r.max.y < list.min.y || r.min.y > list.max.y { return; } - let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click()); + let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click_and_drag()); if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim); highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t); app.auto.add(auto_id, r, name); + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), DragPayload::Preset { preset: preset.clone(), name: name.to_string() }); + } if resp.double_clicked() { - *apply = Some(on_apply); + *apply = Some(Apply::Preset(preset)); } } @@ -395,7 +396,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) { for (id, name) in text { - preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, PresetRef::Text(id.clone()), &mut apply); } } for cat in &categories { @@ -403,7 +404,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) { - preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, PresetRef::Builtin(id.clone()), &mut apply); } } if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) { @@ -417,7 +418,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { &f.name, &format!("effects.preset.file.{}", f.name), &query, - Apply::PresetFile(f.path.clone()), + PresetRef::File(f.path.clone()), &mut apply, ); } @@ -455,10 +456,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let content = y + scroll.offset - list.min.y; scroll.end(ui, &mut app.auto, "effects.scroll", content, &t); - if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::(&ctx).as_deref() - && let Some(pos) = ctx.pointer_hover_pos() + if let Some(name) = egui::DragAndDrop::payload::(&ctx).and_then(|p| match p.as_ref() { + DragPayload::Effect(id) => Some(effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default()), + DragPayload::Preset { name, .. } => Some(name.clone()), + _ => None, + }) && let Some(pos) = ctx.pointer_hover_pos() { - let name = effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default(); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-fx"))); let g = painter.layout_no_wrap(name, Tokens::ui(12.0), t.text); let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0)); @@ -473,9 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let (cmd, params) = match &apply { Apply::Effect(id) => ("effect.apply", json!({"effect": id})), - Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})), - Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})), - Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})), + Apply::Preset(preset) => preset.command(), }; match app.session.execute(cmd, params) { Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c33..4718d79d0 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -75,12 +75,49 @@ pub enum DragPayload { Item(u64), /// An effect id from Effects & Presets. Effect(String), + /// An animation preset from Effects & Presets, with its display name for the drag ghost. + Preset { preset: PresetRef, name: String }, /// A property dragged from the timeline (to the Essential Graphics panel). Property { layer: u64, prop: u64 }, /// Files from the Media Browser (import; into the timeline: import and add). Files(Vec), } +/// An animation preset in Effects & Presets. +#[derive(Clone, Debug, PartialEq)] +pub enum PresetRef { + /// A text animation preset id (`layer.applyTextPreset`). + Text(String), + /// A built-in animation preset id, e.g. a transition (`anim.applyBuiltinPreset`). + Builtin(String), + /// A user `.ecpreset` file path (`anim.applyPreset`). + File(String), +} + +impl PresetRef { + /// The command and params that apply the preset to the selected layers. + pub fn command(&self) -> (&'static str, serde_json::Value) { + match self { + PresetRef::Text(id) => ("layer.applyTextPreset", serde_json::json!({"preset": id})), + PresetRef::Builtin(id) => ("anim.applyBuiltinPreset", serde_json::json!({"preset": id})), + PresetRef::File(path) => ("anim.applyPreset", serde_json::json!({"path": path})), + } + } + + /// The command and params that apply the preset to `layer` (a drop target). + pub fn apply_to(&self, layer: u64) -> (&'static str, serde_json::Value) { + let (id, mut params) = self.command(); + if let Some(obj) = params.as_object_mut() { + // `layer.applyTextPreset` takes one `layer`; the animation preset commands take `layers`. + match self { + PresetRef::Text(_) => obj.insert("layer".into(), serde_json::json!(layer)), + PresetRef::Builtin(_) | PresetRef::File(_) => obj.insert("layers".into(), serde_json::json!([layer])), + }; + } + (id, params) + } +} + /// Layers carried by a Project item drag. Dragging a selected item uses the /// complete selection, while dragging an unselected item uses only that item. /// Folders and data files are not composition layers. @@ -249,3 +286,18 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id)); } } + +#[cfg(test)] +mod preset_drop_tests { + use super::PresetRef; + use serde_json::json; + + #[test] + fn preset_drops_target_the_layer_with_each_commands_param() { + assert_eq!(PresetRef::Text("typewriter".into()).apply_to(7), ("layer.applyTextPreset", json!({"preset": "typewriter", "layer": 7}))); + assert_eq!(PresetRef::Builtin("fadeIn".into()).apply_to(7), ("anim.applyBuiltinPreset", json!({"preset": "fadeIn", "layers": [7]}))); + assert_eq!(PresetRef::File("/p/a.ecpreset".into()).apply_to(7), ("anim.applyPreset", json!({"path": "/p/a.ecpreset", "layers": [7]}))); + // Double-click applies to the selection: no layer given. + assert_eq!(PresetRef::Text("typewriter".into()).command(), ("layer.applyTextPreset", json!({"preset": "typewriter"}))); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..b59a5d96f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3320,7 +3320,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } - // Drop targets: effects from Effects & Presets go on the layer under the pointer. Footage and + // Drop targets: effects and animation presets from Effects & Presets go on the layer under the + // pointer. Footage and // comps from the Project panel and files from the Media Browser go where they are dropped: a // line shows the place in the stack, and over the time graph a marker shows the In point too // (Shift: at the current time). @@ -3329,11 +3330,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect)) { let released = ctx.input(|i| i.pointer.any_released()); - if let crate::panels::DragPayload::Effect(e) = payload.as_ref() { + if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) { p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); if released && let Some(l) = target { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))); + match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => { + let (id, params) = preset.apply_to(l.0); + actions.push((id.into(), params)); + } + _ => {} + } } } else { let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect(); @@ -3369,7 +3377,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } - crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Property { .. } => {} + crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {} } } } diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 2d42eb2c9..44364a49d 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -1,8 +1,8 @@ //! Dropping onto the Composition viewer: footage, comps and solids from the //! Project panel and files from the Media Browser become layers centred where they are dropped -//! (#85), and an effect from Effects & Presets goes on the layer under the pointer, outlined -//! while it is dragged (#88). Agents do the same with `layer.addItem {position}`, -//! `mediaBrowser.import {position}` and `effect.apply {layers}`. +//! (#85), and an effect or animation preset from Effects & Presets goes on the layer under the +//! pointer, outlined while it is dragged (#88). Agents do the same with `layer.addItem {position}`, +//! `mediaBrowser.import {position}`, `effect.apply {layers}` and the preset commands. use effectcraft_engine::project::{ItemId, ItemKind}; use effectcraft_engine::render::EvalCtx; @@ -33,6 +33,15 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain }) .into_iter() .collect(), + DragPayload::Preset { preset, .. } => layer_at(ectx, at) + .map(|l| { + if let Some((_, q, _)) = layer_quad(ectx, l) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + preset.apply_to(l.id.0) + }) + .into_iter() + .collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -45,7 +54,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], DragPayload::Property { .. } => vec![], }; - if actions.first().is_some_and(|(id, _)| *id != "effect.apply") { + // Layer drops outline the target layer; the others outline the whole viewer. + let layer_drop = matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); + if !layer_drop && !actions.is_empty() { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } if ctx.input(|i| i.pointer.any_released()) { From 3a953859f227be26b85d6cb9c1f9d127fd9da6fc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:49 -0400 Subject: [PATCH 09/91] Ease Presets: ease the incoming, outgoing or both sides of selected keyframes (#1329) The panel eased only pairs of neighbouring selected keyframes, so a single selected key (or keys that aren't neighbours) changed nothing. An Ease selector (In, Out, In-Out; In-Out by default) now chooses the sides of each selected key to ease. In eases a key's incoming side with the curve's in side, Out its outgoing side with the curve's out side. A side is eased when it has a neighbouring key, selected or not; the neighbour is left as it is. Each side's speeds scale to the segment on that side, so a key between a +100/s and a -40/s segment gets each its own. The side not eased keeps playing as it did (easeSide, unchanged). Sides are measured before any key changes, and no undo step opens when no selected key has a segment on the chosen side; the status says which neighbour is missing. A selected pair in In-Out still gets the whole curve; its outer sides now ease too when they face an unselected key. From Keys still reads the curve of a selected pair. --- crates/script/src/tests_ease_presets.rs | 5 +- extensions/scriptui-panels/Ease Presets.jsx | 83 ++++++++++++++------- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index c803c27a5..1006d1bf1 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -180,7 +180,8 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension")); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Ease In-Out")); - assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}"); + // In-Out (the default) on every key: each pair gets the whole curve, as before. + assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}"); assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset")); assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset"); @@ -203,7 +204,7 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { s.execute("edit.deselectAll", json!({})).unwrap(); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Linear")); - assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property"); + assert_eq!(status(&mut s, win), "Select keyframes with another keyframe next to it"); assert_eq!(s.history.undo.len(), steps); } diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index a08fdbcc2..0dd3f43ae 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -1,4 +1,4 @@ -// Ease Presets: easing curves kept by name and applied to pairs of neighbouring selected keyframes. +// Ease Presets: easing curves kept by name and applied to the sides of selected keyframes. // // A ScriptUI panel that ships with EffectCraft as an optional extension (Window ▸ Ease // Presets.jsx). It uses only After Effects' documented scripting API: comp.selectedProperties, @@ -12,6 +12,11 @@ // speeds are scaled to it: per dimension, along the motion path for spatial properties, or as // overall progress for shapes. // +// Applied to selected keyframes, In eases each key's incoming side with the curve's in side, Out +// its outgoing side with the curve's out side, In-Out both: a side is eased when it has a +// neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the +// segment on that side. So a selected pair gets the whole curve, and a single key works too. +// // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", // "inSpeed"}}]}. @@ -98,9 +103,8 @@ // ------------------------------------------------------------------ keyframes - // Pairs of neighbouring selected keyframes of the active comp: {prop, key} (the earlier key's - // index), by property. - function selectedPairs() { + // Selected properties of the active comp whose keys can ease: {prop, keys} (their selected keys). + function selectedKeyed() { var comp = app.project.activeItem; var out = []; if (!(comp instanceof CompItem)) return out; @@ -109,7 +113,17 @@ var p = props[i]; if (p.propertyType !== PropertyType.PROPERTY || p.numKeys < 2 || !p.isInterpolationTypeValid(BEZIER)) continue; var keys = p.selectedKeys; - for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: p, key: keys[k] }); + if (keys.length) out.push({ prop: p, keys: keys }); + } + return out; + } + + // Pairs of neighbouring selected keyframes: {prop, key} (the earlier key's index), by property. + function selectedPairs() { + var keyed = selectedKeyed(), out = []; + for (var i = 0; i < keyed.length; i++) { + var keys = keyed[i].keys; + for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: keyed[i].prop, key: keys[k] }); } return out; } @@ -175,29 +189,39 @@ } } - // Ease every pair of neighbouring selected keyframes with `c` (one undo step). Returns the - // number of pairs eased. - function applyCurve(c) { - var pairs = selectedPairs(), n = 0; - if (!pairs.length) return 0; + // The eases of one side with `speed` (relative to the segment's average) and `influence`. + function eases(s, speed, influence) { + var out = []; + for (var d = 0; d < s.length; d++) out.push(new KeyframeEase(speed * s[d], influence)); + return out; + } + + // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in + // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns the + // number of keyframes eased. + function applyCurve(c, mode) { + var keyed = selectedKeyed(), sides = [], keys = 0; + var doIn = mode !== "Out", doOut = mode !== "In"; + // Every side and its eases first: the segments are measured before any key changes. + for (var i = 0; i < keyed.length; i++) { + var p = keyed[i].prop; + for (var j = 0; j < keyed[i].keys.length; j++) { + var k = keyed[i].keys[j], before = sides.length; + var s = doIn && k > 1 ? slopes(p, k - 1) : null; + if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + s = doOut && k < p.numKeys ? slopes(p, k) : null; + if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (sides.length > before) keys++; + } + } + if (!sides.length) return 0; app.beginUndoGroup("Apply Ease Preset"); try { - for (var i = 0; i < pairs.length; i++) { - var p = pairs[i].prop, a = pairs[i].key, s = slopes(p, a); - if (!s) continue; - var outE = [], inE = []; - for (var d = 0; d < s.length; d++) { - outE.push(new KeyframeEase(c.outSpeed * s[d], c.outInfluence)); - inE.push(new KeyframeEase(c.inSpeed * s[d], c.inInfluence)); - } - easeSide(p, a, true, outE); - easeSide(p, a + 1, false, inE); - n++; - } + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); } finally { app.endUndoGroup(); } - return n; + return keys; } // The curve between the first selected pair of keyframes (measured on the dimension that @@ -288,6 +312,13 @@ var inInfluence = field(inRow, "In %", "inInfluence"); var inSpeed = field(inRow, "Speed", "inSpeed"); + var modeRow = ui.add("group"); + modeRow.add("statictext", undefined, "Ease"); + var MODES = ["In", "Out", "In-Out"]; + var mode = modeRow.add("dropdownlist", undefined, MODES, { name: "mode" }); + mode.selection = 2; + mode.helpTip = "The sides of each selected keyframe to ease: incoming, outgoing or both"; + var actions = ui.add("group"); var applyButton = actions.add("button", undefined, "Apply", { name: "apply" }); var fromKeys = actions.add("button", undefined, "From Keys", { name: "fromKeys" }); @@ -346,8 +377,10 @@ } function apply(c, what) { - var n = applyCurve(c); - status.text = n > 0 ? "Eased " + n + " keyframe pair(s) with " + what : "Select two or more neighbouring keyframes of a property"; + var m = mode.selection ? mode.selection.text : "In-Out"; + var n = applyCurve(c, m); + var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; + status.text = n > 0 ? "Eased " + n + " keyframe(s) (" + m + ") with " + what : "Select keyframes with another keyframe " + side; return n; } From af486dc44a86dd639ca65f1d465061f50add6ebb Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:50 -0400 Subject: [PATCH 10/91] Ease Presets: tests for easing single keyframes In, Out and In-Out (#1329) One middle key between unequal segments in each mode, with its neighbours, times, values and selection unchanged and one undo restoring it; first and last keys, keys that aren't neighbours and keys of several properties; no undo step when nothing can ease; a Hold and an auto-Bezier side kept as they play; per-dimension and motion-path speeds on Scale and Position; and the pair's outer sides under In-Out and Out. All but the unchanged API tests fail on main. --- crates/script/src/tests_ease_presets.rs | 194 ++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index 1006d1bf1..4d05d7c6f 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -208,6 +208,200 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { assert_eq!(s.history.undo.len(), steps); } +/// Select only the keys at `keys` (property path, time in seconds). +fn select_keys(s: &mut Session, l: u64, keys: &[(&str, f64)]) { + s.execute("edit.deselectAll", json!({})).unwrap(); + let keys: Vec = keys.iter().map(|(path, t)| json!({"layer": l, "path": path, "time": t})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); +} + +/// Type a curve (Out %, Out speed, In %, In speed), choose the sides to ease and click Apply. +fn apply_typed(s: &mut Session, win: u64, mode: &str, c: [f64; 4]) { + for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) { + set(s, win, name, json!(v.to_string())); + } + set(s, win, "mode", json!(mode)); + click(s, win, "apply"); +} + +fn eased(e: Ease, speed: f64, influence: f64) -> bool { + close(e.speed, speed, 1e-9) && close(e.influence, influence, 1e-9) +} + +/// #1329: one selected keyframe eases its incoming side (In), its outgoing side (Out) or both, +/// each scaled to the segment on that side (+100/s in, −40/s out here), without changing its +/// neighbours, key times, values or the selection; one undo step restores it. +#[test] +fn one_selected_keyframe_eases_its_in_out_or_both_sides() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + // In-Out is the default. + assert_eq!(control(&mut s, win, "mode")["selection"], json!([2])); + let before = prop(&s, l, "transform/opacity"); + for mode in ["In", "Out", "In-Out"] { + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + let selected = s.state.selected_keys.clone(); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, [30.0, 0.5, 60.0, 2.0]); + assert_eq!(status(&mut s, win), format!("Eased 1 keyframe(s) ({mode}) with the curve")); + assert_eq!(s.history.undo.len(), steps + 1, "{mode}: one undo step"); + assert_eq!(s.state.selected_keys, selected, "{mode}: the selection stays"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((&op.keys[0], &op.keys[2]), (&before.keys[0], &before.keys[2]), "{mode}: the neighbours don't change"); + assert_eq!((op.keys[1].time, op.keys[1].value.as_f64()), (before.keys[1].time, 100.0), "{mode}"); + let k = &op.keys[1]; + let (ins, outs) = (mode != "Out", mode != "In"); + assert_eq!(k.in_interp, if ins { Interp::Bezier } else { Interp::Linear }, "{mode}"); + assert_eq!(k.out_interp, if outs { Interp::Bezier } else { Interp::Linear }, "{mode}"); + if ins { + assert!(eased(k.in_ease[0], 2.0 * 100.0, 0.6), "{mode}: {:?}", k.in_ease); + } else { + assert!(close(op.value_at(at(0.5)).as_f64(), 50.0, 1e-6), "{mode}: the in side still plays linearly"); + } + if outs { + assert!(eased(k.out_ease[0], 0.5 * -40.0, 0.3), "{mode}: {:?}", k.out_ease); + } else { + assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6), "{mode}: the out side still plays linearly"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys, "{mode}: undone"); + } +} + +/// A side without a neighbouring key isn't eased; when no selected key has one, nothing changes +/// (no undo step) and the panel says why. Keys that aren't neighbours, and keys of several +/// properties, each ease on their own. +#[test] +fn first_last_and_scattered_keyframes_ease_the_sides_that_have_a_segment() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + for (mode, t, why) in [("In", 0.0, "before it"), ("Out", 3.0, "after it")] { + select_keys(&mut s, l, &[("transform/opacity", t)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, c); + assert_eq!(status(&mut s, win), format!("Select keyframes with another keyframe {why}")); + assert_eq!(s.history.undo.len(), steps, "{mode}: no undo step"); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + } + // In-Out on the first key eases only its out side. + select_keys(&mut s, l, &[("transform/opacity", 0.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[0].in_interp, op.keys[0].out_interp), (Interp::Linear, Interp::Bezier)); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert_eq!(op.keys[1..], before.keys[1..]); + s.execute("edit.undo", json!({})).unwrap(); + // The first and last Opacity keys (not neighbours) and the last Rotation key. + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 3.0), ("transform/rotation", 2.0)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, "In-Out", c); + assert_eq!(status(&mut s, win), "Eased 3 keyframe(s) (In-Out) with the curve"); + assert_eq!(s.history.undo.len(), steps + 1); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert!(eased(op.keys[2].in_ease[0], 2.0 * -40.0, 0.6), "{:?}", op.keys[2].in_ease); + assert_eq!(op.keys[1], before.keys[1], "the unselected middle key"); + let rot = prop(&s, l, "transform/rotation"); + assert!(eased(rot.keys[1].in_ease[0], 2.0 * 45.0, 0.6), "{:?}", rot.keys[1].in_ease); + assert_eq!(rot.keys[0].out_interp, Interp::Linear, "the unselected neighbour"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + assert!(prop(&s, l, "transform/rotation").keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear)); +} + +/// A change from the pair-only panel: In-Out on a selected pair also eases the pair's outer sides +/// that face an unselected key (the selected keys' own sides), and Out on the pair eases only +/// out sides. +#[test] +fn a_selected_pair_eases_the_sides_its_mode_names() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].in_ease[0], 200.0, 0.6)); + assert!(eased(op.keys[1].out_ease[0], -20.0, 0.3), "the side facing the unselected key: {:?}", op.keys[1].out_ease); + assert_eq!(op.keys[2], before.keys[2]); + s.execute("edit.undo", json!({})).unwrap(); + apply_typed(&mut s, win, "Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].out_ease[0], -20.0, 0.3)); + assert_eq!((op.keys[0].in_interp, op.keys[1].in_interp), (Interp::Linear, Interp::Linear)); +} + +/// Easing one side of a key leaves the other playing as it did: a Hold stays a Hold, and an +/// auto-Bezier side keeps its shape. A preset click uses the chosen sides too. +#[test] +fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var t = app.project.activeItem.layer(1).transform, K = KeyframeInterpolationType; + t.opacity.setInterpolationTypeAtKey(2, K.LINEAR, K.HOLD); + t.rotation.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + t.rotation.setTemporalAutoBezierAtKey(2, true);"#, + ); + let rot = prop(&s, l, "transform/rotation"); + let after: Vec = [1.5, 2.0, 2.5].iter().map(|t| rot.value_at(at(*t)).as_f64()).collect(); + let win = open_panel(&mut s); + set(&mut s, win, "mode", json!("In")); + select_keys(&mut s, l, &[("transform/opacity", 1.0), ("transform/rotation", 1.0)]); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In) with \u{201c}Ease In-Out\u{201d}"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[1].in_interp, op.keys[1].out_interp), (Interp::Bezier, Interp::Hold)); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5), "{:?}", op.keys[1].in_ease); + assert_eq!(op.value_at(at(2.0)).as_f64(), 100.0, "still holds"); + let eased_rot = prop(&s, l, "transform/rotation"); + assert!(eased(eased_rot.keys[1].in_ease[0], 0.0, 0.5), "{:?}", eased_rot.keys[1].in_ease); + for (t, want) in [1.5, 2.0, 2.5].iter().zip(after) { + assert!(close(eased_rot.value_at(at(*t)).as_f64(), want, 1e-6), "the auto-Bezier out side at {t}"); + } + assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed"); +} + +/// On one key, each side scales to its own segment: per dimension, and along the motion path. +#[test] +fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + // Scale: +100/−50 per second into the key, 0/+50 per second out of it. + animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (3.0, json!([200, 150]))]); + // Position on a straight path: 100 px/s into the key, 50 px/s out of it. + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([200, 0, 0]))]); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/scale", 1.0), ("transform/position", 1.0)]); + apply_typed(&mut s, win, "In-Out", [40.0, 1.5, 50.0, 2.0]); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In-Out) with the curve"); + let k = prop(&s, l, "transform/scale").keys[1].clone(); + assert!(eased(k.in_ease[0], 200.0, 0.5) && eased(k.in_ease[1], -100.0, 0.5), "{:?}", k.in_ease); + assert!(eased(k.out_ease[0], 0.0, 0.4) && eased(k.out_ease[1], 75.0, 0.4), "{:?}", k.out_ease); + let k = prop(&s, l, "transform/position").keys[1].clone(); + assert!(k.in_ease.iter().all(|e| close(e.speed, 200.0, 0.5) && close(e.influence, 0.5, 1e-9)), "{:?}", k.in_ease); + assert!(k.out_ease.iter().all(|e| close(e.speed, 75.0, 0.5) && close(e.influence, 0.4, 1e-9)), "{:?}", k.out_ease); + let pos = prop(&s, l, "transform/position"); + assert_eq!(pos.value_at(at(1.0)).as_vec2()[0], 100.0, "the key's value stays"); +} + #[test] fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() { let store = Arc::new(MemoryConfig::default()); From c0f919a8589ad49e6dd0603cd9e2ab5b5e35d240 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:50 -0400 Subject: [PATCH 11/91] Docs: plugins.md describes the Ease Presets In, Out and In-Out sides (#1329) --- docs/plugins.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index fc7c0b8ff..861739b8b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -103,17 +103,22 @@ name in your Scripts folders takes their place. ### Ease Presets (`extensions/scriptui-panels/Ease Presets.jsx`) -Window ▸ Ease Presets.jsx: easing curves kept by name and applied to pairs of neighbouring -selected keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's +Window ▸ Ease Presets.jsx: easing curves kept by name and applied to the sides of selected +keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's first key and the in side of its second, in the Keyframe Velocity dialog's terms: influence in percent, and speed relative to the segment's average speed (1 = as fast as a straight line, 0 = at rest), so one curve fits any duration and change of value. -* Click a preset to apply it to every pair of neighbouring selected keyframes of each selected - property, in one undo step (per dimension, and along the motion path for spatial properties; - the keys' other sides keep playing as they did). The keys stay selected, so you can try another. +* Click a preset to apply it to the selected keyframes of each selected property, in one undo + step. **Ease** chooses the sides (#1329): **In** eases each key's incoming side with the curve's + in side, **Out** its outgoing side with the curve's out side, **In-Out** (the default) both. A + side is eased when it has a neighbouring keyframe, selected or not (the neighbour doesn't + change), with its speeds scaled to the segment on that side: per dimension, and along the + motion path for spatial properties. So a single keyframe works, a selected pair gets the whole + curve, and the first and last keys ease the side that has a segment. The side not eased keeps + playing as it did (a Hold stays a Hold). The keys stay selected, so you can try another. * The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and - click Apply, or click From Keys to read the curve between the first selected pair. + click Apply, or click From Keys to read the curve between the first selected pair of neighbouring keys. * Save keeps the working curve under the typed name (replacing a preset of that name); Rename and Delete act on the selected preset of your own. The twelve built-in presets can't change. * User presets are kept with `app.settings` (section `Ease Presets`, key `userPresets`, in From f62af4ca73bbb33a4d1e1ab5602cfac101e29683 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:55:43 +0200 Subject: [PATCH 12/91] Guide layers: hidden in nested comps, and in the Timeline context menu, as in After Effects Guide layers render only in their own comp's viewer: a nested renderer (precomp, collapsed precomp in 2D, Classic 3D and Advanced 3D, comps read by effects, aux passes, the GPU walk's resident precomps) now runs with guides off (Renderer::nested_opts), so a precomp's guide layers no longer show in the parent comp's viewer or in a "Current Settings" render. The Timeline layer and Composition viewer right-click menus (shared layer_menu) gain a Guide Layer item (a plain toggle, no check mark) acting on the selection, and guide layers show a guide mark before their name in the Timeline. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 18 +++- crates/render/src/tests_guides.rs | 108 ++++++++++++++++++++ crates/render/src/three_d/adv/scene.rs | 2 +- crates/render/src/three_d/compose.rs | 2 +- crates/ui-egui/src/i18n/ui.rs | 7 ++ crates/ui-egui/src/panels/timeline.rs | 48 +++++++-- crates/ui-egui/src/panels/viewer.rs | 2 +- crates/ui-egui/tests/ui_guide_layers.rs | 129 ++++++++++++++++++++++++ 8 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 crates/render/src/tests_guides.rs create mode 100644 crates/ui-egui/tests/ui_guide_layers.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..45581d7f3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -533,7 +533,9 @@ pub struct RenderOpts { /// Output scale relative to comp pixels (1 = Full, 0.5 = Half…). pub scale: f64, pub motion_blur: bool, - /// Render guide layers (viewer only). + /// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested + /// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in + /// After Effects (see [`Renderer::nested_opts`]). pub guides: bool, /// Draft quality: fewer motion-blur samples, bilinear only. pub draft: bool, @@ -737,9 +739,16 @@ impl<'a> Renderer<'a> { sub } + /// The render options one level down (precomps, collapsed precomps, comps read by effects): + /// guide layers render only in their own comp, never when it is nested. + fn nested_opts(&self) -> RenderOpts { + RenderOpts { guides: false, ..self.opts } + } + /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { Renderer { + opts: self.nested_opts(), depth: self.depth + 1, outer: None, opacity_mul: 1.0, @@ -830,7 +839,10 @@ impl<'a> Renderer<'a> { if let Some(slot) = slot { *slot = Some(link); } - Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx)) + Some(( + Renderer { opts: self.nested_opts(), depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, + nctx, + )) } pub(crate) fn opacity_mul(&self) -> f32 { @@ -2388,6 +2400,8 @@ mod tests_footage_size; #[cfg(test)] mod tests_frame_blend; #[cfg(test)] +mod tests_guides; +#[cfg(test)] mod tests_mask_blur; #[cfg(test)] mod tests_nested; diff --git a/crates/render/src/tests_guides.rs b/crates/render/src/tests_guides.rs new file mode 100644 index 000000000..cb9851250 --- /dev/null +++ b/crates/render/src/tests_guides.rs @@ -0,0 +1,108 @@ +//! Guide layers: drawn in their own comp's viewer (`RenderOpts::guides`), never when that comp is +//! nested in another (precomp, collapsed precomp, 2D or 3D), and never in final renders. + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), three_d: bool) -> Layer { + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + l.switches.three_d = three_d; + l +} + +#[derive(Clone, Copy, Debug)] +enum Nest { + /// A plain precomp layer (the nested comp rendered on its own, then composited). + Precomp, + /// Collapse Transformations on a 2D precomp layer. + Collapsed, + /// A collapsed 3D precomp in a Classic 3D comp. + Collapsed3d, + /// A collapsed 3D precomp in an Advanced 3D comp. + CollapsedAdvanced, +} + +/// "Inner": a red 100×100 background with a green 40×40 guide layer in the middle; "Outer": the +/// same size, holding only Inner nested as `nest`. +fn scene(nest: Nest) -> (Project, ItemId, ItemId) { + let three_d = matches!(nest, Nest::Collapsed3d | Nest::CollapsedAdvanced); + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut guide = solid(&mut p, &inner, [0.0, 1.0, 0.0], (40, 40), three_d); + guide.switches.guide = true; + let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d); + let mut inner = inner; + inner.layers = vec![guide, bg]; + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + if matches!(nest, Nest::CollapsedAdvanced) { + outer.renderer = effectcraft_project::Renderer::Advanced3D; + } + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + pre.switches.collapse = !matches!(nest, Nest::Precomp); + pre.switches.three_d = three_d; + outer.layers = vec![pre]; + let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + (p, iid, oid) +} + +/// The centre pixel of `comp` rendered with or without guide layers. +fn centre(p: &Project, comp: ItemId, guides: bool) -> [f32; 4] { + Renderer::new(p, &NoFootage, RenderOpts { guides, ..Default::default() }).comp_frame(comp, Tick::ZERO).get(50, 50) +} + +fn is_green(c: [f32; 4]) -> bool { + c[1] > 0.9 && c[0] < 0.1 +} + +fn is_red(c: [f32; 4]) -> bool { + c[0] > 0.9 && c[1] < 0.1 +} + +#[test] +fn guide_layer_renders_in_its_own_comp_with_guides_on() { + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, true); + assert!(is_green(c), "guide layer drawn in its own comp's viewer: {c:?}"); +} + +#[test] +fn final_renders_leave_guide_layers_out_by_default() { + assert!(!RenderOpts::default().guides); + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, false); + assert!(is_red(c), "guide layer left out of final renders: {c:?}"); +} + +#[test] +fn guide_layers_of_a_nested_comp_never_render_in_the_parent() { + for nest in [Nest::Precomp, Nest::Collapsed, Nest::Collapsed3d, Nest::CollapsedAdvanced] { + let (p, inner, outer) = scene(nest); + // The nested comp's own viewer still shows them… + let own = centre(&p, inner, true); + assert!(is_green(own), "{nest:?}: guide layer in its own comp: {own:?}"); + // …but not the parent's viewer (guides on) nor its final render. + for guides in [true, false] { + let c = centre(&p, outer, guides); + assert!(is_red(c), "{nest:?}, guides {guides}: nested guide layer drawn in the parent: {c:?}"); + } + } +} + +#[test] +fn a_guide_precomp_layer_of_the_top_comp_follows_the_guides_option() { + // The precomp layer itself is a guide layer in the top comp: shown in the viewer, not in + // final renders. + let (mut p, _, outer) = scene(Nest::Precomp); + p.comp_mut(outer).unwrap().layers[0].switches.guide = true; + assert!(is_red(centre(&p, outer, true))); + assert!(centre(&p, outer, false)[3] < 0.01, "nothing drawn without guides"); +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 5b5ae956a..f1f4c185f 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -879,7 +879,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: || l.is_light() || matches!(l.source, LayerSource::Camera) || (any_solo && !l.switches.solo) - || (!r.opts.guides && l.switches.guide) + || (!sub.opts.guides && l.switches.guide) { continue; } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 0d70b32fa..ca7998fe0 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -503,7 +503,7 @@ fn collapsed_items<'a>( .layers .iter() .rev() - .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide)) + .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (sub.opts.guides || !l.switches.guide)) .collect(); let mut items = Vec::new(); for (k, l) in nested.into_iter().enumerate() { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..64a1b6d1a 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -262,6 +262,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Grab bag", "いろいろ"), ("Gradient Editor", "グラデーションエディター"), ("Grid", "グリッド"), + ("Guide Layer", "ガイドレイヤー"), ("Guide Layers: {}", "ガイドレイヤー:{}"), ("Guides", "ガイド"), ("Height:", "高さ:"), @@ -806,6 +807,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "列表"), ("Gradient Editor", "渐变编辑器"), ("Grid", "网格"), + ("Guide Layer", "参考线图层"), ("Guide Layers: {}", "参考图层:{}"), ("Guides", "参考线"), ("Height:", "高度:"), @@ -1538,6 +1540,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "清單"), ("Gradient Editor", "漸變編輯器"), ("Grid", "格點"), + ("Guide Layer", "參考圖層"), ("Guide Layers: {}", "參考圖層:{}"), ("Guides", "參考線"), ("Height:", "高度:"), @@ -2273,6 +2276,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Grab bag", "Список"), ("Gradient Editor", "Редактор градієнта"), ("Grid", "Сітка"), + ("Guide Layer", "Допоміжний шар"), ("Guide Layers: {}", "Допоміжні шари: {}"), ("Guides", "Напрямні"), ("Height:", "Висота:"), @@ -2823,6 +2827,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Frame Target:", "Destino do quadro:"), ("Grab bag", "Lista"), ("Grid", "Grade"), + ("Guide Layer", "Camada de guia"), ("Guide Layers: {}", "Camadas de guia: {}"), ("Guides", "Guias"), ("Height:", "Altura:"), @@ -3274,6 +3279,7 @@ const CZECH: &[(&str, &str)] = &[ ("Freeze", "Zmrazit"), ("Grab bag", "Mozaika"), ("Grid", "Mřížka"), + ("Guide Layer", "Pomocná vrstva"), ("Guide Layers: {}", "Pomocné vrstvy: {}"), ("Guides", "Vodítka"), ("Height:", "Výška:"), @@ -4310,6 +4316,7 @@ mod tests { set_current("cs"); assert_eq!(tr("Composition Name:"), "Název kompozice:"); assert_eq!(tr("Cancel"), "Zrušit"); + assert_eq!(tr("Guide Layer"), "Pomocná vrstva", "the layer context menus' item, as Layer ▸ Guide Layer"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Použití zástupných souborů: Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Krok 1 z 3: Colour"); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..d00dbd5e7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2042,7 +2042,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl"); icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim); - let name_rect = Rect::from_min_max(pos2(cw.name + 34.0, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); + // A guide layer carries a guide mark before its name (After Effects). + let name_x = if layer.switches.guide { + let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0)); + icons::paint(&lp, mark, Icon::Grid, t.text_dim); + app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer"); + cw.name + 50.0 + } else { + cw.name + 34.0 + }; + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); let rename_id = egui::Id::new("tl-rename"); let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id)); if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) { @@ -2103,7 +2112,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), @@ -2324,7 +2333,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); let drag_key = format!("bar-{}", layer.id.0); // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the // drag starts and followed below the rows until the button is released. @@ -3857,12 +3866,14 @@ impl LayerMenuCaps { /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part /// of the selection keeps the selection and the menu acts on all of it; right-clicking an /// unselected layer selects only that layer. +#[allow(clippy::too_many_arguments)] fn layer_context_menu( resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], caps: LayerMenuCaps, + auto: &mut crate::automation::Registry, actions: &mut Vec<(String, serde_json::Value)>, ) { let in_selection = selected.contains(&layer.id); @@ -3870,12 +3881,24 @@ fn layer_context_menu( actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); } let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; - crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, actions)); + crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions)); } -/// Shared layer actions for Timeline rows and Composition context menus. -pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { +/// The layer switches the layer context menus toggle (After Effects lists them after Time): +/// `layer.setSwitch` name and label. +const MENU_SWITCHES: [(&str, &str); 1] = [("guide", "Guide Layer")]; + +/// Shared layer actions for Timeline rows and Composition context menus. `auto` is the automation +/// registry and the id prefix of the menu's items. +pub(crate) fn layer_menu( + ui: &mut egui::Ui, + layers: &[u64], + caps: LayerMenuCaps, + auto: (&mut crate::automation::Registry, &str), + actions: &mut Vec<(String, serde_json::Value)>, +) { let Some(&id) = layers.first() else { return }; + let (auto, prefix) = auto; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { if ui.button(label).clicked() { actions.push(("layer.select".into(), json!({"layers": layers}))); @@ -3921,6 +3944,18 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, ui.menu_button(crate::i18n::tr("Markers"), |ui| { item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); }); + // A click toggles the switch on the target layers, as Layer ▸ Guide Layer does. + // (Pushed after the other items: `item` holds `actions` until its last use.) + let mut switch_actions: Vec<(String, serde_json::Value)> = Vec::new(); + for (switch, label) in MENU_SWITCHES { + let response = ui.button(crate::i18n::tr(label)); + auto.add(&format!("{prefix}.switch.{switch}"), response.rect, label); + if response.clicked() { + switch_actions.push(("layer.select".into(), json!({"layers": layers}))); + switch_actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": switch}))); + ui.close(); + } + } ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| { widgets::menu_scroll(ui, |ui| { for m in BlendMode::ALL { @@ -3943,6 +3978,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, item(ui, "Duplicate", "edit.duplicate", json!({"layers": layers})); item(ui, "Split Layer", "edit.splitLayer", json!({"layers": layers})); item(ui, "Delete", "edit.clear", json!({"layers": layers})); + actions.extend(switch_actions); } /// The layer context menu's Effects submenu: every effect from the registry grouped by category, diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a881..324ed3dd8 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1274,7 +1274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { caps }, ); - super::timeline::layer_menu(ui, &context_layers, caps, &mut context_actions); + super::timeline::layer_menu(ui, &context_layers, caps, (&mut app.auto, "viewer.layerMenu"), &mut context_actions); } ui.separator(); if ui.button(copy_frame).clicked() { diff --git a/crates/ui-egui/tests/ui_guide_layers.rs b/crates/ui-egui/tests/ui_guide_layers.rs new file mode 100644 index 000000000..37158658f --- /dev/null +++ b/crates/ui-egui/tests/ui_guide_layers.rs @@ -0,0 +1,129 @@ +//! Guide Layer in the layer context menus (Timeline rows and the Composition viewer), a plain item +//! (no check mark) that toggles the switch on the selection, and the guide mark by a guide layer's +//! name. +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use serde_json::json; + +/// A 640×360 comp with two 200×200 solids, "Left" at (160, 180) and "Right" at (480, 180). +fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, LayerId) { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Guides", "width": 640, "height": 360, "duration": 4})).unwrap(); + let mut solid = |name: &str, x: f64| { + let id = session.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + session.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [x, 180.0]})).unwrap(); + LayerId(id) + }; + let left = solid("Left", 160.0); + let right = solid("Right", 480.0); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(3); + (h, left, right) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn guide(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> bool { + h.state().session.active_comp().unwrap().layer(id).unwrap().switches.guide +} + +fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) { + if let Ok(dir) = std::env::var("GUIDE_LAYER_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + } +} + +/// The open menu's Guide Layer item carries no check mark (a plain toggle). +fn plain(h: &Harness<'_, EffectcraftApp>) -> bool { + !h.get_by_label_contains("Guide Layer").accesskit_node().label().unwrap_or_default().contains('✔') +} + +fn row(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Pos2 { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) +} + +/// The Guide Layer item of the open menu `prefix`. +fn guide_item(h: &Harness<'_, EffectcraftApp>, prefix: &str) -> Rect { + rect(h, &format!("{prefix}.switch.guide")) +} + +#[test] +fn timeline_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_none(), "no guide mark yet"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![left], "right-click selects the row"); + assert!(plain(&h), "no check mark"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left), "Guide Layer turned on"); + assert!(!guide(&h, right), "only the target layer"); + // The guide mark shows by the name. + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_some(), "guide mark by the name"); + // Again: off. Undo brings it back. + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert!(plain(&h), "no check mark on a guide layer either"); + snapshot(&mut h, "timeline-menu"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left), "Guide Layer turned off"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(guide(&h, left)); +} + +#[test] +fn timeline_layer_menu_guide_layer_acts_on_the_selection() { + let (mut h, left, right) = harness(); + h.state_mut().session.execute("layer.select", json!({"layers": [left.0, right.0]})).unwrap(); + h.run_steps(3); + let p = row(&h, right); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left) && guide(&h, right), "both selected layers become guide layers"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left) && !guide(&h, right), "and back together"); +} + +#[test] +fn viewer_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + let comp = rect(&h, "viewer.comp"); + let k = comp.width() / 640.0; + let at = |x: f32| comp.min + vec2(x, 180.0) * k; + click(&mut h, at(480.0), PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![right], "right-click picks the layer under the pointer"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, right) && !guide(&h, left)); + // The Timeline and viewer menus share the item: the viewer menu turns it off again. + click(&mut h, at(480.0), PointerButton::Secondary); + assert!(plain(&h), "no check mark"); + snapshot(&mut h, "viewer-menu"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, right)); +} From 9b74dba2a89d647be8b7300c91d4278b4378bbc7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 13/91] Ease Presets: skip roving keyframes and say so, rather than reporting them eased (#1329) A roving key passes speed straight through: the engine keeps both its sides linear whenever the keys are edited (retime_roving), so an ease set on one never plays. The panel still counted it as eased, so a selected roving key showed "Eased 1 keyframe(s)" and stayed linear. The previous pair-only panel did the same to a pair holding a roving key. Selected roving keys are now left out of the work and counted. The status names how many were skipped and that turning off Roving lets them ease; when nothing else was selected, nothing changes and no undo step opens. Fixed keys next to a roving key still ease. --- crates/script/src/tests_ease_presets.rs | 35 +++++++++++++++++++++ extensions/scriptui-panels/Ease Presets.jsx | 22 ++++++++----- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index 4d05d7c6f..c9871d14a 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -378,6 +378,41 @@ fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() { assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed"); } +/// Roving keys pass speed straight through (the engine keeps their sides linear), so an ease set +/// on one wouldn't play: they are skipped and the panel says so, rather than reporting them eased. +#[test] +fn roving_keyframes_are_skipped_and_reported() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([200, 0, 0]))]); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + ok(&mut s, "app.project.activeItem.layer(1).transform.position.setRovingAtKey(2, true)"); + let pos = prop(&s, l, "transform/position"); + assert!(pos.keys[1].roving); + let roving_at = pos.keys[1].time.seconds(); + let win = open_panel(&mut s); + // Only the roving key: nothing changes, no undo step. + select_keys(&mut s, l, &[("transform/position", roving_at)]); + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Nothing eased: skipped 1 roving keyframe(s); turn off Roving to ease them"); + assert_eq!(s.history.undo.len(), steps); + assert_eq!(prop(&s, l, "transform/position").keys, pos.keys); + // With an ordinary key: that one is eased, the roving one is counted as skipped. + select_keys(&mut s, l, &[("transform/position", roving_at), ("transform/opacity", 1.0)]); + set(&mut s, win, "presets", json!("Linear")); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!( + status(&mut s, win), + "Eased 1 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}; skipped 1 roving keyframe(s); turn off Roving to ease them" + ); + let k = &prop(&s, l, "transform/position").keys[1]; + assert!(k.roving && k.in_interp == Interp::Linear && k.out_interp == Interp::Linear, "{k:?}"); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5)); +} + /// On one key, each side scales to its own segment: per dimension, and along the motion path. #[test] fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index 0dd3f43ae..6f6eadae5 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -16,6 +16,8 @@ // its outgoing side with the curve's out side, In-Out both: a side is eased when it has a // neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the // segment on that side. So a selected pair gets the whole curve, and a single key works too. +// Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease +// set on them wouldn't play. // // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", @@ -197,16 +199,20 @@ } // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in - // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns the - // number of keyframes eased. + // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns + // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. function applyCurve(c, mode) { - var keyed = selectedKeyed(), sides = [], keys = 0; + var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; var doIn = mode !== "Out", doOut = mode !== "In"; // Every side and its eases first: the segments are measured before any key changes. for (var i = 0; i < keyed.length; i++) { var p = keyed[i].prop; for (var j = 0; j < keyed[i].keys.length; j++) { var k = keyed[i].keys[j], before = sides.length; + if (p.keyRoving(k)) { + roving++; + continue; + } var s = doIn && k > 1 ? slopes(p, k - 1) : null; if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); s = doOut && k < p.numKeys ? slopes(p, k) : null; @@ -214,14 +220,14 @@ if (sides.length > before) keys++; } } - if (!sides.length) return 0; + if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); } finally { app.endUndoGroup(); } - return keys; + return { eased: keys, roving: roving }; } // The curve between the first selected pair of keyframes (measured on the dimension that @@ -378,9 +384,11 @@ function apply(c, what) { var m = mode.selection ? mode.selection.text : "In-Out"; - var n = applyCurve(c, m); + var r = applyCurve(c, m), n = r.eased; var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; - status.text = n > 0 ? "Eased " + n + " keyframe(s) (" + m + ") with " + what : "Select keyframes with another keyframe " + side; + var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; + if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); + else status.text = skipped ? "Nothing eased: " + skipped : "Select keyframes with another keyframe " + side; return n; } From 4dddb34cbab47076a5e1f01e8bc90db526b4c28c Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 14/91] Ease Presets: tests for Out alone through undo and redo, and a single key on a curved path (#1329) Out on an auto-Bezier key leaves its in side playing the same values, and undo and redo restore each state. On a curved Position path with unequal segment lengths and durations, In sets the key's speed from the incoming path length, playback arrives faster than a constant speed, and the out side plays as before. --- crates/script/src/tests_ease_presets.rs | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index c9871d14a..009cc59c6 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -413,6 +413,65 @@ fn roving_keyframes_are_skipped_and_reported() { assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5)); } +/// Out alone leaves an auto-Bezier in side playing as it did, and undo and redo restore each state. +#[test] +fn out_alone_keeps_the_in_side_playing_through_undo_and_redo() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var r = app.project.activeItem.layer(1).transform.rotation, K = KeyframeInterpolationType; + r.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + r.setTemporalAutoBezierAtKey(2, true);"#, + ); + let before = prop(&s, l, "transform/rotation"); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/rotation", 1.0)]); + apply_typed(&mut s, win, "Out", [60.0, 0.0, 33.0, 1.0]); + let after = prop(&s, l, "transform/rotation"); + assert!(eased(after.keys[1].out_ease[0], 0.0, 0.6), "{:?}", after.keys[1].out_ease); + for t in [0.25, 0.5, 0.75] { + assert!(close(after.value_at(at(t)).as_f64(), before.value_at(at(t)).as_f64(), 1e-6), "the in side at {t}"); + } + assert!(!close(after.value_at(at(2.0)).as_f64(), before.value_at(at(2.0)).as_f64(), 1e-3), "the out side changed"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, before.keys); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, after.keys); +} + +/// On a curved path, a single key's in and out speeds follow the length of the path on each side +/// (unequal lengths and durations), and the side not eased plays as it did. +#[test] +fn a_single_key_on_a_curved_path_eases_along_each_side() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([100, 300, 0]))]); + let before = prop(&s, l, "transform/position"); + let (len_in, len_out) = (effectcraft_keyframe::spatial_segment_length(&before.keys, 0), effectcraft_keyframe::spatial_segment_length(&before.keys, 1)); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/position", 1.0)]); + apply_typed(&mut s, win, "In", [40.0, 1.5, 50.0, 2.0]); + let pos = prop(&s, l, "transform/position"); + let k = &pos.keys[1]; + // In speed: twice the incoming segment's average (its path length over 1 s). + assert!(k.in_ease.iter().all(|e| close(e.speed, 2.0 * len_in, 0.05 * len_in) && close(e.influence, 0.5, 1e-9)), "{:?} vs {len_in}", k.in_ease); + // The out side (2 s along a longer path) plays as before. + for t in [1.5, 2.0, 2.5] { + let (a, b) = (pos.value_at(at(t)).as_vec2(), before.value_at(at(t)).as_vec2()); + assert!(close(a[0], b[0], 1e-6) && close(a[1], b[1], 1e-6), "out side at {t}: {a:?} vs {b:?}"); + } + // Playback on the in side: arriving at twice the average speed, the last tenth of a second + // covers more of the path than a constant speed would (len_in / 10). + let (p9, p10) = (pos.value_at(at(0.9)).as_vec2(), pos.value_at(at(1.0)).as_vec2()); + let step = ((p10[0] - p9[0]).powi(2) + (p10[1] - p9[1]).powi(2)).sqrt(); + assert!(step > len_in / 10.0 * 1.2, "{step} vs {len_in}"); + assert!(len_out > len_in, "{len_out} {len_in}"); +} + /// On one key, each side scales to its own segment: per dimension, and along the motion path. #[test] fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { From a37357f082a77df31d8c438cb2e2f9560142ee50 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 15/91] Docs: plugins.md notes that Ease Presets skips roving keyframes (#1329) --- docs/plugins.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plugins.md b/docs/plugins.md index 861739b8b..a688a89b0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -116,7 +116,9 @@ rest), so one curve fits any duration and change of value. change), with its speeds scaled to the segment on that side: per dimension, and along the motion path for spatial properties. So a single keyframe works, a selected pair gets the whole curve, and the first and last keys ease the side that has a segment. The side not eased keeps - playing as it did (a Hold stays a Hold). The keys stay selected, so you can try another. + playing as it did (a Hold stays a Hold). Roving keyframes are skipped, and the status says how + many: they pass speed straight through, so an ease on one wouldn't play (turn off Roving to + ease it). The keys stay selected, so you can try another. * The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and click Apply, or click From Keys to read the curve between the first selected pair of neighbouring keys. * Save keeps the working curve under the typed name (replacing a preset of that name); Rename From da73df4b2eba41f2882a0a2eb283e6f770c6eb51 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:03:53 -0600 Subject: [PATCH 16/91] Script sockets: per-socket locks, a write deadline, and close interrupts (#1248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every socket operation locked one global table and held it through the blocking write_all/flush, and streams had no write timeout (accepted ones were explicitly blocking). A peer that stopped reading kept the script blocked for good, and every other socket call, close included, waited behind it. - The table maps handles to Arc, each with its own Mutex. The table is locked only to look a socket up, add or remove one, never during network I/O, so one stalled socket doesn't hold up the others. - `write` sets a write timeout from the socket's `timeout` (10 s by default, as reads) and reports "write timed out … the other side isn't reading" instead of blocking. Failures to set the read or write timeout, or to make an accepted stream blocking, are reported instead of ignored. - `close` removes the socket and shuts its stream down through a second handle, so a call blocked on it fails at once. Tests (socket.rs, native only): a 64 MB write to a loopback peer that never reads fails with "timed out" after a 1 s timeout; while one socket is blocked writing, another answers at once, and closing the blocked one returns at once and ends its write. Both fail on main (the write never gives up; the second socket waits on the table lock). Co-Authored-By: Claude Opus 5.5 --- crates/script/src/socket.rs | 168 +++++++++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 23 deletions(-) diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 6ac0d41d2..4030332de 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -50,8 +50,8 @@ mod imp { mod imp { use std::collections::BTreeMap; use std::io::{ErrorKind, Read, Write}; - use std::net::{TcpListener, TcpStream, ToSocketAddrs}; - use std::sync::Mutex; + use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use super::*; @@ -61,9 +61,18 @@ mod imp { Listener(TcpListener), } + /// One open socket. Reading, writing and polling lock only it, never the whole table, so a + /// socket blocked on a slow peer doesn't hold up the others (#1248). + struct Entry { + sock: Mutex, + /// A second handle on a stream, so `close` can shut it down while another call is + /// blocked reading or writing it. + wake: Option, + } + struct Table { next: u32, - socks: BTreeMap, + socks: BTreeMap>, } fn table() -> &'static Mutex { @@ -72,13 +81,31 @@ mod imp { } fn add(s: Sock) -> u32 { + let wake = match &s { + Sock::Stream { s, .. } => s.try_clone().ok(), + Sock::Listener(_) => None, + }; + let entry = Arc::new(Entry { sock: Mutex::new(s), wake }); let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); let id = t.next; t.next += 1; - t.socks.insert(id, s); + t.socks.insert(id, entry); id } + /// The socket `id`, if open. The table is locked only for the lookup. + fn get(id: u32) -> Option> { + table().lock().unwrap_or_else(|e| e.into_inner()).socks.get(&id).cloned() + } + + /// Forget socket `id`; a call blocked on it fails at once. + fn close(id: u32) { + let entry = table().lock().unwrap_or_else(|e| e.into_inner()).socks.remove(&id); + if let Some(w) = entry.as_ref().and_then(|e| e.wake.as_ref()) { + let _ = w.shutdown(Shutdown::Both); + } + } + fn timeout(p: &J) -> Duration { Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) } @@ -98,8 +125,9 @@ mod imp { if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() } } - /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. - fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) { + /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. Fails only when + /// the read deadline can't be set. + fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) -> std::io::Result<()> { let end = Instant::now() + limit; let mut chunk = [0u8; 4096]; while !done(buf) && !*eof { @@ -107,7 +135,7 @@ mod imp { if left.is_zero() { break; } - let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1)))); + s.set_read_timeout(Some(left.max(Duration::from_millis(1))))?; match s.read(&mut chunk) { Ok(0) => *eof = true, Ok(n) => buf.extend_from_slice(&chunk[..n]), @@ -119,6 +147,7 @@ mod imp { Err(_) => *eof = true, } } + Ok(()) } pub(super) fn run(op: &str, id: u32, p: &J) -> J { @@ -156,29 +185,27 @@ mod imp { Err(e) => err(format!("cannot listen on port {port}: {e}")), } } + "close" => { + close(id); + json!({}) + } _ => { - let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); - if op == "close" { - t.socks.remove(&id); - return json!({}); - } - let Some(sock) = t.socks.get_mut(&id) else { + let Some(entry) = get(id) else { return match op { "connected" => json!({"value": false}), "eof" => json!({"value": true}), _ => err("the socket is not open"), }; }; - match (op, sock) { + let mut sock = entry.sock.lock().unwrap_or_else(|e| e.into_inner()); + match (op, &mut *sock) { ("poll", Sock::Listener(l)) => match l.accept() { Ok((s, peer)) => { - let _ = s.set_nonblocking(false); + if let Err(e) = s.set_nonblocking(false) { + return err(format!("cannot use the connection from {peer}: {e}")); + } let host = peer.to_string(); - let new = Sock::Stream { s, buf: vec![], eof: false }; - let nid = t.next; - t.next += 1; - t.socks.insert(nid, new); - json!({"id": nid, "host": host}) + json!({"id": add(Sock::Stream { s, buf: vec![], eof: false }), "host": host}) } Err(_) => json!({"id": null}), }, @@ -202,19 +229,25 @@ mod imp { let count = p.get("count").and_then(J::as_i64).unwrap_or(-1); let limit = timeout(p); if count < 0 { - fill(s, buf, eof, limit, |_| false); + if let Err(e) = fill(s, buf, eof, limit, |_| false) { + return err(format!("cannot set the read timeout: {e}")); + } let all = std::mem::take(buf); json!({"data": decode(&all, utf8)}) } else { let n = count as usize; - fill(s, buf, eof, limit, |b| b.len() >= n); + if let Err(e) = fill(s, buf, eof, limit, |b| b.len() >= n) { + return err(format!("cannot set the read timeout: {e}")); + } let take = n.min(buf.len()); let out: Vec = buf.drain(..take).collect(); json!({"data": decode(&out, utf8)}) } } ("readln", Sock::Stream { s, buf, eof }) => { - fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')); + if let Err(e) = fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')) { + return err(format!("cannot set the read timeout: {e}")); + } let line: Vec = match buf.iter().position(|&c| c == b'\n') { Some(i) => { let mut l: Vec = buf.drain(..=i).collect(); @@ -229,9 +262,19 @@ mod imp { json!({"data": decode(&line, utf8)}) } ("write", Sock::Stream { s, .. }) => { + // A peer that stops reading fills the buffers: give up after `timeout` + // instead of blocking the script for good (#1248). + let limit = timeout(p).max(Duration::from_millis(1)); + if let Err(e) = s.set_write_timeout(Some(limit)) { + return err(format!("cannot set the write timeout: {e}")); + } let data = p.get("data").and_then(J::as_str).unwrap_or(""); match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { Ok(()) => json!({"ok": true}), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!( + "write timed out after {} s: the other side isn't reading (some of the data may have been sent)", + limit.as_secs_f64() + )), Err(e) => err(e), } } @@ -242,3 +285,82 @@ mod imp { } } } + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + use serde_json::{Value as J, json}; + + use super::imp::run; + + /// A loopback peer that accepts one connection and never reads from it; returns its + /// `host:port` and the accepted stream's keeper (drop it to let the connection go). + fn stalled_peer() -> (String, mpsc::Sender<()>) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let host = l.local_addr().unwrap().to_string(); + let (stop, wait) = mpsc::channel::<()>(); + std::thread::spawn(move || { + let (_s, _): (TcpStream, _) = l.accept().unwrap(); + let _ = wait.recv_timeout(Duration::from_secs(120)); + }); + (host, stop) + } + + fn open(host: &str) -> u32 { + let r = run("open", 0, &json!({"host": host, "timeout": 5})); + r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32 + } + + /// More than the loopback buffers hold, so a peer that doesn't read stops the write. + fn big() -> String { + "x".repeat(64 * 1024 * 1024) + } + + /// Run `f` on a thread; its result, or `None` if it takes longer than `limit`. + fn within(limit: Duration, f: impl FnOnce() -> T + Send + 'static) -> Option { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(limit).ok() + } + + #[test] + fn a_write_to_a_peer_that_stops_reading_fails_after_the_timeout() { + // #1248: it used to block for good. + let (host, _keep) = stalled_peer(); + let id = open(&host); + let data = big(); + let t0 = Instant::now(); + let r = within(Duration::from_secs(20), move || run("write", id, &json!({"data": data, "timeout": 1}))).expect("the write gave up"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("timed out")), "{r}"); + assert!(t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed()); + run("close", id, &J::Null); + } + + #[test] + fn a_blocked_socket_doesnt_hold_up_the_others_and_close_interrupts_it() { + let (host_a, _keep_a) = stalled_peer(); + let (host_b, _keep_b) = stalled_peer(); + let (a, b) = (open(&host_a), open(&host_b)); + // A write on A that would wait a minute. + let data = big(); + let (tx, blocked) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(run("write", a, &json!({"data": data, "timeout": 60}))); + }); + std::thread::sleep(Duration::from_millis(500)); + // Socket B answers meanwhile (the table isn't locked by A's write). + let r = within(Duration::from_secs(3), move || run("connected", b, &json!({}))).expect("socket B answered while A was blocked"); + assert_eq!(r["value"], true, "{r}"); + // Closing A returns at once and ends A's write. + within(Duration::from_secs(3), move || run("close", a, &J::Null)).expect("close returned while A was blocked"); + let r = blocked.recv_timeout(Duration::from_secs(5)).expect("A's write ended when A was closed"); + assert!(r.get("error").is_some(), "{r}"); + run("close", b, &J::Null); + } +} From 79ebd019352508b0b461b557dc5110d328f61839 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:59:08 -0600 Subject: [PATCH 17/91] svg: bound expansion by an element budget and skip reference cycles (#1236) Depth-limited expansion still let a few kilobytes of branching references expand to 2^N elements. The parser now counts every element it converts, copies included, against a budget of max(500 000, twice the file's own elements) and fails the import with Error::Xml once it is spent. Targets currently being expanded are tracked, so mutual references stop at the first repeat instead of nesting to the depth limit. Fixes #1236 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/svg/src/lib.rs | 26 +++++++++++++++--- crates/svg/src/tests.rs | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 7310814bf..b5ddd98e6 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -486,6 +486,12 @@ struct Parser<'a, 'i> { vw: f64, vh: f64, skipped: Vec, + /// Elements converted so far, `` copies included, and how many may be (#1236). + work: usize, + budget: usize, + /// The `` targets being expanded, outermost first: a reference back into one is a + /// cycle and is skipped. + using: Vec, } fn is_svg_ns(n: &roxmltree::Node) -> bool { @@ -748,9 +754,12 @@ impl<'a, 'i> Parser<'a, 'i> { /// Convert an element (and its subtree) into nodes appended to `out`. fn node(&mut self, n: roxmltree::Node<'a, 'i>, parent: &Style, out: &mut Vec, depth: usize) { - if depth > 64 || !is_svg_ns(&n) { + if depth > 64 || !is_svg_ns(&n) || self.work > self.budget { return; } + // Every element converted counts, each `` copy again: a few references to groups + // that reference each other several times can multiply into billions (#1236). + self.work += 1; let tag = n.tag_name().name(); match tag { "defs" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "symbol" | "clipPath" | "mask" | "pattern" | "marker" @@ -816,9 +825,10 @@ impl<'a, 'i> Parser<'a, 'i> { } "use" => { let Some(target) = self.href(n) else { return }; - if target == n || n.ancestors().any(|a| a == target) { + if target == n || n.ancestors().any(|a| a == target) || self.using.contains(&target.id()) { return; } + self.using.push(target.id()); let x = self.attr_len(n, "x", 'x', &st); let y = self.attr_len(n, "y", 'y', &st); transform *= Affine::translate((x, y)); @@ -847,6 +857,7 @@ impl<'a, 'i> Parser<'a, 'i> { } else { self.node(target, &st, &mut children, depth + 1); } + self.using.pop(); out.push(Node::Group(Group { name: Self::element_name(n), transform, @@ -1048,6 +1059,9 @@ pub fn parse_transform(s: &str) -> Affine { /// 2 MiB thread's stack (aborting the app) at a few thousand levels; real documents stay far /// below this. const MAX_XML_DEPTH: usize = 256; +/// The most elements an import converts, `` copies included, unless the file itself has +/// more than half as many (#1236). +const MAX_EXPANDED_ELEMENTS: usize = 500_000; /// The deepest element nesting of `text` (a quick scan that skips comments, CDATA, processing /// instructions, declarations and quoted attribute values). @@ -1121,7 +1135,9 @@ pub fn parse(bytes: &[u8]) -> Result { } } let vb = viewbox(root); - let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![] }; + // Room for generous reuse, and always for every element of the file itself. + let budget = MAX_EXPANDED_ELEMENTS.max(root.descendants().filter(|n| n.is_element()).count().saturating_mul(2)); + let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![], work: 0, budget, using: vec![] }; let st = Style::default(); let pct_or = |name: &str, axis: char, def: f64| -> f64 { match root.attribute(name) { @@ -1157,6 +1173,10 @@ pub fn parse(bytes: &[u8]) -> Result { for c in root.children() { p.node(c, &rst, &mut children, 1); } + if p.work > p.budget { + // Never a silently partial drawing. + return Err(Error::Xml(format!("its references expand to more than {} elements", p.budget))); + } let transform = viewbox_transform(root, dw, dh); Ok(Doc { width: dw, diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index ed1ea873e..93db715c1 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -217,3 +217,63 @@ fn deep_nesting_errors_instead_of_overflowing_the_stack() { assert_eq!(crate::xml_depth(&s), 2); assert!(crate::parse(s.as_bytes()).is_ok()); } + +/// How deep `g`'s groups nest, and how many shapes it holds. +fn depth_and_shapes(g: &Group) -> (usize, usize) { + let mut depth = 0; + let mut shapes = 0; + for c in &g.children { + match c { + Node::Group(sub) => { + let (d, s) = depth_and_shapes(sub); + depth = depth.max(d + 1); + shapes += s; + } + Node::Shape(_) => shapes += 1, + Node::Image(_) => {} + } + } + (depth, shapes) +} + +#[test] +fn branching_use_references_stop_at_the_expansion_budget() { + // Each group uses the one before twice: 30 levels are 2^30 copies of one rectangle, from a + // file of a few kilobytes (#1236). + let mut defs = String::from(r#""#); + for i in 1..=30 { + defs += &format!(r##""##, p = i - 1); + } + let svg = format!(r##"{defs}"##); + let t0 = std::time::Instant::now(); + let e = parse(svg.as_bytes()).unwrap_err(); + assert!(e.to_string().contains("expand to more than"), "{e}"); + assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed()); + // Below the budget the same pattern imports whole: 2^10 rectangles. + let small = svg.replace(r##""##, r##""##); + let doc = parse(small.as_bytes()).unwrap(); + assert_eq!(depth_and_shapes(&doc.root).1, 1024); +} + +#[test] +fn mutually_referencing_uses_are_cut_at_the_first_repeat() { + // A uses B and B uses A, neither inside the other: once each, not down to the depth limit. + let svg = r##" + + + "##; + let doc = parse(svg.as_bytes()).unwrap(); + let (depth, shapes) = depth_and_shapes(&doc.root); + assert_eq!(shapes, 2, "a's and b's rectangle, once each"); + assert!(depth <= 6, "{depth}"); +} + +#[test] +fn a_symbol_used_a_thousand_times_still_imports() { + let uses: String = (0..1000).map(|i| format!(r##""##)).collect(); + let svg = format!( + r##"{uses}"## + ); + let doc = parse(svg.as_bytes()).unwrap(); + assert_eq!(depth_and_shapes(&doc.root).1, 3000); +} From 3d45046132aa841fc768fde3d8f7b59dc4e23f7f Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 17:35:25 -0600 Subject: [PATCH 18/91] model: validate glTF accessor spans before allocating, with checked arithmetic (#1235) Accessor counts sized the f32/u32 output before the buffer view was looked at, and offsets, strides and count * components used unchecked arithmetic, so a few hundred bytes of JSON could request a multi-gigabyte allocation (aborting the process) or overflow. - Doc::layout checks count * components, adds the view and accessor offsets, rejects a byteStride smaller than an element, and checks that the last element ends inside the buffer, all with checked arithmetic, before floats() or ints() allocate anything. - Accessors without a buffer view (all zeros) are capped at 2^24 components. - idx() and node index lists use usize::try_from instead of 'as usize'; an image bufferView's end and a GLB chunk's end are computed with checked_add. Fixes #1235 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/model/src/gltf.rs | 87 +++++++++++++++++++++++++++++---------- crates/model/src/tests.rs | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index 20578d4f4..e17d014f6 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -39,13 +39,15 @@ pub fn split_glb(b: &[u8]) -> Result<(&[u8], Option<&[u8]>)> { while o + 8 <= total { let len = u32_at(o).unwrap_or(0) as usize; let ty = u32_at(o + 4).unwrap_or(0); - let data = b.get(o + 8..o + 8 + len).ok_or_else(|| perr("GLB chunk runs past the end of the file"))?; + let past_end = || perr("GLB chunk runs past the end of the file"); + let end = (o + 8).checked_add(len).ok_or_else(past_end)?; + let data = b.get(o + 8..end).ok_or_else(past_end)?; match ty { CHUNK_JSON if json.is_none() => json = Some(data), CHUNK_BIN if bin.is_none() => bin = Some(data), _ => {} } - o += 8 + len; + o = end; } Ok((json.ok_or_else(|| perr("GLB without a JSON chunk"))?, bin)) } @@ -115,8 +117,25 @@ struct Doc<'a> { fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] { v.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) } +/// A non-negative integer field; one too large for `usize` counts as absent (#1235). fn idx(v: &Value, k: &str) -> Option { - v.get(k).and_then(Value::as_u64).map(|x| x as usize) + v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()) +} +/// Node indices in an array field (out-of-range ones are dropped). +fn indices(v: &Value, k: &str) -> Vec { + arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).collect() +} + +/// The most components an accessor without a buffer view (all zeros) may have: nothing in the +/// file backs its count, so it is capped instead of checked against a buffer (#1235). +const MAX_ZERO_COMPONENTS: usize = 1 << 24; + +/// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride` +/// apart from `base`, all checked to be inside `buf`. +struct Span<'b> { + buf: &'b [u8], + base: usize, + stride: usize, } fn num(v: &Value, k: &str, d: f64) -> f64 { v.get(k).and_then(Value::as_f64).unwrap_or(d) @@ -132,6 +151,39 @@ fn nums(v: &Value, k: &str, d: [f64; N]) -> [f64; N] { } impl Doc<'_> { + /// The accessor's element count and total components (`count * n`), checked; and its span in + /// the buffer, validated before anything is allocated from the count. `None` for an + /// accessor without a buffer view, whose components are all zero. + fn layout(&self, acc: &Value, n: usize, csize: usize) -> Result<(usize, usize, Option>)> { + let count = match acc.get("count") { + None => 0, + Some(c) => c.as_u64().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| perr("accessor count is not a valid size"))?, + }; + let total = count.checked_mul(n).ok_or_else(|| perr("accessor count is too large"))?; + let Some(bv) = idx(acc, "bufferView") else { + if total > MAX_ZERO_COMPONENTS { + return Err(perr(format!("accessor without a bufferView has {total} components (at most {MAX_ZERO_COMPONENTS})"))); + } + return Ok((count, total, None)); + }; + let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; + let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; + let past = || perr("accessor reads past its buffer"); + let base = idx(view, "byteOffset").unwrap_or(0).checked_add(idx(acc, "byteOffset").unwrap_or(0)).ok_or_else(past)?; + let elem = csize * n; + let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); + if stride < elem { + return Err(perr(format!("bufferView byteStride {stride} is smaller than its {elem}-byte elements"))); + } + if count > 0 { + let end = (count - 1).checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(elem)).ok_or_else(past)?; + if end > buf.len() { + return Err(past()); + } + } + Ok((count, total, Some(Span { buf, base, stride }))) + } + /// An accessor as `f32` rows of `n` components (normalised integers mapped to 0..1 / -1..1). fn floats(&self, a: usize) -> Result<(Vec, usize)> { let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?; @@ -145,7 +197,6 @@ impl Doc<'_> { "MAT4" => 16, t => return Err(perr(format!("accessor type `{t}`"))), }; - let count = idx(acc, "count").unwrap_or(0); let ct = idx(acc, "componentType").unwrap_or(5126); let norm = acc.get("normalized").and_then(Value::as_bool).unwrap_or(false); let csize = match ct { @@ -154,17 +205,13 @@ impl Doc<'_> { 5125 | 5126 => 4, _ => return Err(perr(format!("componentType {ct}"))), }; - let mut out = vec![0.0f32; count * n]; - let Some(bv) = idx(acc, "bufferView") else { + let (count, total, span) = self.layout(acc, n, csize)?; + let mut out = vec![0.0f32; total]; + let Some(Span { buf, base, stride }) = span else { // No buffer view: all zeros (sparse substitution is not supported). return Ok((out, n)); }; - let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; - let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; - let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0); - let elem = csize * n; // Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare). - let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); for i in 0..count { for c in 0..n { let o = base + i * stride + c * csize; @@ -207,18 +254,14 @@ impl Doc<'_> { "VEC2" => 2, _ => 1, }; - let count = idx(acc, "count").unwrap_or(0); let csize = match ct { 5120 | 5121 => 1, 5122 | 5123 => 2, _ => 4, }; - let Some(bv) = idx(acc, "bufferView") else { return Ok(vec![0; count * n]) }; - let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; - let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; - let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0); - let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(csize * n); - let mut out = Vec::with_capacity(count * n); + let (count, total, span) = self.layout(acc, n, csize)?; + let Some(Span { buf, base, stride }) = span else { return Ok(vec![0; total]) }; + let mut out = Vec::with_capacity(total); for i in 0..count { for c in 0..n { let o = base + i * stride + c * csize; @@ -268,7 +311,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< arr(&j, "bufferViews").get(bv).and_then(|v| { let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?; let o = idx(v, "byteOffset").unwrap_or(0); - b.get(o..o + idx(v, "byteLength").unwrap_or(0)).map(<[u8]>::to_vec) + b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec) }) } else { None @@ -366,13 +409,13 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< skin: idx(n, "skin"), camera: idx(n, "camera"), light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")), - children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(), + children: indices(n, "children"), }); } let scene = idx(&j, "scene").unwrap_or(0); let roots: Vec = match arr(&j, "scenes").get(scene) { - Some(s) => arr(s, "nodes").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(), + Some(s) => indices(s, "nodes"), None => { // No scene: every node that is nobody's child. let mut child = vec![false; nodes.len()]; @@ -389,7 +432,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< let mut skins = vec![]; for s in arr(&j, "skins") { - let joints: Vec = arr(s, "joints").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(); + let joints: Vec = indices(s, "joints"); let inverse_bind = match idx(s, "inverseBindMatrices") { Some(a) => { let (f, _) = d.floats(a)?; diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 9206ef83f..7c98c1ae7 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -492,3 +492,75 @@ fn stl_hostile_input_is_an_error() { let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap(); assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]); } + +/// A one-triangle glTF whose POSITION is accessor 0, over a buffer of `bytes` zero bytes. +fn gltf_doc(accessors: &str, views: &str, bytes: usize) -> String { + let b64 = "A".repeat(bytes / 3 * 4) + ["", "AA==", "AAA="][bytes % 3]; + format!( + r#"{{"asset": {{"version": "2.0"}}, "scenes": [{{"nodes": [0]}}], "nodes": [{{"mesh": 0}}], + "meshes": [{{"primitives": [{{"attributes": {{"POSITION": 0}}}}]}}], + "buffers": [{{"byteLength": {bytes}, "uri": "data:application/octet-stream;base64,{b64}"}}], + "bufferViews": [{views}], "accessors": [{accessors}]}}"# + ) +} + +#[test] +fn gltf_accessor_counts_are_checked_against_the_buffer_before_allocating() { + // Untrusted counts, offsets and strides must fail the import, not allocate from the count or + // overflow (#1235). + let view = r#"{"buffer": 0, "byteLength": 36}"#; + let cases = [ + // A count far beyond the 36-byte buffer. + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 4000000000}"#, view, "past its buffer"), + // count * components overflows. + (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 9223372036854775807}"#, view, "too large"), + // Offsets that overflow when added. + ( + r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "byteOffset": 16}"#, + r#"{"buffer": 0, "byteOffset": 18446744073709551610}"#, + "past its buffer", + ), + // A stride that overflows with the count. + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3000000000}"#, r#"{"buffer": 0, "byteStride": 252}"#, "past its buffer"), + // A stride smaller than an element would re-read the same bytes for a huge count. + (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 30}"#, r#"{"buffer": 0, "byteStride": 1}"#, "smaller than"), + // No buffer view: nothing backs the count, so it is capped. + (r#"{"componentType": 5126, "type": "VEC3", "count": 1000000000}"#, view, "without a bufferView"), + ]; + let t0 = std::time::Instant::now(); + for (acc, v, want) in cases { + let doc = gltf_doc(acc, v, 36); + let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("{acc} imported")); + assert!(e.to_string().contains(want), "{acc}: {e}"); + } + assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed()); +} + +#[test] +fn gltf_integer_accessors_are_checked_too() { + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteLength": 36}"#, 36) + .replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1"#); + for idx in [ + r#"{"bufferView": 0, "componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, + r#"{"componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, + r#"{"bufferView": 0, "componentType": 5125, "type": "VEC4", "count": 9223372036854775807}"#, + ] { + let doc = doc.replace(r#""count": 3}]"#, &format!(r#""count": 3}}, {idx}]"#)); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{idx}"); + } +} + +#[test] +fn gltf_strided_and_buffer_less_accessors_still_import() { + // Interleaved positions (stride 24) ending exactly at the buffer's end, and a small all-zero + // accessor without a view. + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 60); + let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives[0].positions.len(), 3); + let doc = gltf_doc(r#"{"componentType": 5126, "type": "VEC3", "count": 6}"#, r#"{"buffer": 0}"#, 3); + let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives[0].positions.len(), 6); + // One byte short of the third element is an error. + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 59); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err()); +} From 4b475f57815e3743c0a44db95d7410fe4150ce44 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 17:44:27 -0600 Subject: [PATCH 19/91] model: budget the memory an FBX import keeps, and share instanced meshes (#1234) The 1 GiB array limit counted wire bytes, but arrays are kept as f64/i64, so boolean arrays grew 8x and 32-bit arrays 2x past it, and nothing bounded the total across arrays. Every Model linked to a Geometry also built its own copy of the mesh. - A per-import Budget (2 GiB) is charged 8 bytes per array element before the array is decompressed or widened, ASCII files are charged for their parsed values, and each built mesh is charged for its vertex and index data. Going past it fails the import with a clear error instead of an allocation abort. parse_within/read_within take a lower limit for tests. - Models instancing the same geometry with the same materials share one mesh; a different material mapping still gets its own. Fixes #1234 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/model/src/fbx.rs | 102 ++++++++++++++++++++++++++++++---- crates/model/src/fbx_tests.rs | 84 ++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/crates/model/src/fbx.rs b/crates/model/src/fbx.rs index e2b9e1bd0..8a3fd9f40 100644 --- a/crates/model/src/fbx.rs +++ b/crates/model/src/fbx.rs @@ -32,6 +32,57 @@ pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0"; const MAX_DEPTH: usize = 64; /// Largest decoded array accepted (bytes). const MAX_ARRAY_BYTES: usize = 1 << 30; +/// Most memory one import may keep: parsed arrays as stored (8 bytes per element, whatever the +/// wire type) plus the meshes built from them (#1234). +const MAX_IMPORT_BYTES: usize = 1 << 31; + +/// What is left of an import's memory budget. +struct Budget(std::cell::Cell); + +impl Budget { + fn new(bytes: usize) -> Budget { + Budget(std::cell::Cell::new(bytes)) + } + /// Take `bytes` (None: a size that overflowed) from the budget, or fail the import before + /// anything that large is allocated. + fn take(&self, bytes: Option, what: &str) -> Result<()> { + let left = self.0.get(); + match bytes.filter(|&b| b <= left) { + Some(b) => { + self.0.set(left - b); + Ok(()) + } + None => Err(perr(format!("FBX: {what} would take the import past its {} MiB memory limit", MAX_IMPORT_BYTES >> 20))), + } + } +} + +/// Bytes a mesh keeps. +fn mesh_bytes(m: &Mesh) -> Option { + m.primitives.iter().try_fold(0usize, |acc, p| { + let n = p.positions.len().checked_mul(12)?.checked_add(p.normals.len().checked_mul(12)?)?.checked_add(p.uvs.len().checked_mul(8)?)?; + let n = n.checked_add(p.tangents.len().checked_mul(16)?)?.checked_add(p.joints.len().checked_mul(8)?)?.checked_add(p.weights.len().checked_mul(16)?)?; + acc.checked_add(n)?.checked_add(p.indices.len().checked_mul(4)?) + }) +} + +/// Bytes the values under `nodes` keep. Used for ASCII files, which hold array elements as +/// one `Prop` each and are parsed before they can be counted (the text itself bounds them). +fn value_bytes(nodes: &[FbxNode]) -> Option { + nodes.iter().try_fold(0usize, |acc, n| { + let own = n.props.iter().try_fold(0usize, |a, p| { + let extra = match p { + Prop::Ints(v) => v.len().checked_mul(8)?, + Prop::Floats(v) => v.len().checked_mul(8)?, + Prop::Str(v) => v.len(), + Prop::Bytes(v) => v.len(), + Prop::Int(_) | Prop::Float(_) => 0, + }; + a.checked_add(std::mem::size_of::())?.checked_add(extra) + })?; + acc.checked_add(own)?.checked_add(value_bytes(&n.children)?) + }) +} /// One property value of an FBX node. #[derive(Clone, Debug, PartialEq)] @@ -126,7 +177,17 @@ impl FbxNode { /// Read an FBX file (binary or ASCII) into its top-level nodes and the file version /// (e.g. 7400). pub fn read(bytes: &[u8]) -> Result<(u32, Vec)> { - if bytes.starts_with(BINARY_MAGIC) { read_binary(bytes) } else { read_ascii(&String::from_utf8_lossy(bytes)) } + read_within(bytes, &Budget::new(MAX_IMPORT_BYTES)) +} + +fn read_within(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { + if bytes.starts_with(BINARY_MAGIC) { + read_binary(bytes, budget) + } else { + let (version, nodes) = read_ascii(&String::from_utf8_lossy(bytes))?; + budget.take(value_bytes(&nodes), "its values")?; + Ok((version, nodes)) + } } // --------------------------------------------------------------------------------------------- @@ -136,6 +197,7 @@ struct Bin<'a> { d: &'a [u8], /// 64-bit record offsets (version 7500 and later). wide: bool, + budget: &'a Budget, } impl Bin<'_> { @@ -207,7 +269,7 @@ impl Bin<'_> { } fn prop(&self, pos: &mut usize, pend: usize) -> Result { - let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide }; + let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide, budget: self.budget }; let t = sub.arr::<1>(pos)?[0]; Ok(match t { b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64), @@ -234,6 +296,9 @@ impl Bin<'_> { _ => 1, }; let want = len.checked_mul(size).filter(|&n| n <= MAX_ARRAY_BYTES).ok_or_else(|| perr("FBX: array too large"))?; + // Elements are kept as f64/i64 (booleans and 32-bit values widen), so the budget + // is charged for what is kept, not for the wire bytes. + sub.budget.take(len.checked_mul(8), "an array")?; let data = sub.take(pos, clen)?; let raw = match enc { 0 => data.to_vec(), @@ -263,10 +328,10 @@ fn object_name(b: &[u8]) -> String { } } -fn read_binary(bytes: &[u8]) -> Result<(u32, Vec)> { +fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { let mut pos = BINARY_MAGIC.len(); - let version = Bin { d: bytes, wide: false }.u32(&mut pos)?; - let bin = Bin { d: bytes, wide: version >= 7500 }; + let version = Bin { d: bytes, wide: false, budget }.u32(&mut pos)?; + let bin = Bin { d: bytes, wide: version >= 7500, budget }; let mut nodes = vec![]; while pos < bytes.len() { match bin.record(&mut pos, 0) { @@ -728,7 +793,13 @@ fn short_name(full: &str) -> &str { /// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it. pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { - let (version, doc) = read(bytes)?; + parse_within(bytes, resolve, MAX_IMPORT_BYTES) +} + +/// [`parse`] keeping at most `limit` bytes of arrays and meshes. +fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: usize) -> Result { + let budget = Budget::new(limit); + let (version, doc) = read_within(bytes, &budget)?; if version != 0 && version < 7000 { return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100))); } @@ -835,19 +906,28 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< Some(materials.len() - 1) }; - // One mesh per Model with Mesh geometry (material slots are per Model). + // One mesh per Model with Mesh geometry (material slots are per Model). Models instancing the + // same geometry with the same materials share one mesh rather than each building a copy. let mut meshes: Vec = vec![]; + let mut mesh_of: HashMap<(i64, Vec), usize> = HashMap::new(); for &id in &model_ids { let Some(&ni) = node_of.get(&id) else { continue }; let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue }; let Some(geom) = by_id.get(&gid) else { continue }; let mats: Vec = links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect(); - let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default(); - let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; - meshes.push(mesh); - let mesh_index = meshes.len() - 1; + let mesh_index = match mesh_of.get(&(gid, mats.clone())) { + Some(&i) => i, + None => { + let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); + let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; + budget.take(mesh_bytes(&mesh), &format!("mesh `{name}`"))?; + meshes.push(mesh); + mesh_of.insert((gid, mats), meshes.len() - 1); + meshes.len() - 1 + } + }; let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o))); match geo { Some(matrix) => { diff --git a/crates/model/src/fbx_tests.rs b/crates/model/src/fbx_tests.rs index 3cd8ba5a1..1ba1978ba 100644 --- a/crates/model/src/fbx_tests.rs +++ b/crates/model/src/fbx_tests.rs @@ -325,3 +325,87 @@ fn hostile_fbx_files_fail_cleanly() { assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_)))); assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err()); } + +/// A version-7400 binary FBX holding one top-level node `A` with uncompressed arrays of type +/// `t` (`b'b'`, `b'i'`, `b'f'`, `b'd'`, `b'l'`) and `len` elements each. +fn binary_arrays(arrays: &[(u8, usize)]) -> Vec { + let mut props = vec![]; + for &(t, len) in arrays { + let size = match t { + b'd' | b'l' => 8, + b'f' | b'i' => 4, + _ => 1, + }; + props.push(t); + props.extend((len as u32).to_le_bytes()); + props.extend(0u32.to_le_bytes()); + props.extend(((len * size) as u32).to_le_bytes()); + props.extend(std::iter::repeat_n(1u8, len * size)); + } + let mut out = BINARY_MAGIC.to_vec(); + out.extend(7400u32.to_le_bytes()); + let end = out.len() + 13 + 1 + props.len(); + out.extend((end as u32).to_le_bytes()); + out.extend((arrays.len() as u32).to_le_bytes()); + out.extend((props.len() as u32).to_le_bytes()); + out.push(1); + out.push(b'A'); + out.extend(props); + out +} + +#[test] +fn fbx_arrays_are_charged_for_the_memory_they_keep() { + // A boolean array is one byte per element on the wire but eight once kept as `Prop::Ints`: + // the budget counts the eight (#1234). + let n = 10_000; + let bin = binary_arrays(&[(b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 8)).is_ok()); + let e = read_within(&bin, &Budget::new(n * 8 - 1)).unwrap_err(); + assert!(e.to_string().contains("memory limit"), "{e}"); + // 32-bit floats widen to f64 the same way. + assert!(read_within(&binary_arrays(&[(b'f', n)]), &Budget::new(n * 4)).is_err()); + // Several arrays each within the budget but not together. + let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 24)).is_ok()); + assert!(read_within(&bin, &Budget::new(n * 20)).is_err()); + // ASCII values count too. + let (_, doc) = read(ASCII.as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(values > 45 * std::mem::size_of::(), "{values}"); + assert!(parse_within(ASCII.as_bytes(), &|_| None, values - 1).unwrap_err().to_string().contains("memory limit")); +} + +/// [`ASCII`] with two more Models instancing its geometry: `Copy` with the same two materials, +/// `Red only` with just the first. +fn instanced_scene() -> String { + ASCII + .replace( + "\tMaterial: 400,", + "\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\n\tMaterial: 400,", + ) + .replace( + "\tC: \"OO\",500,300\n", + "\tC: \"OO\",500,300\n\tC: \"OO\",301,0\n\tC: \"OO\",100,301\n\tC: \"OO\",400,301\n\tC: \"OO\",500,301\n\tC: \"OO\",302,0\n\tC: \"OO\",100,302\n\tC: \"OO\",400,302\n", + ) +} + +#[test] +fn fbx_models_instancing_one_geometry_share_its_mesh() { + let m = parse(instanced_scene().as_bytes(), &|_| None).unwrap(); + let mesh_of = |name: &str| { + let n = m.nodes.iter().position(|n| n.name == name).unwrap(); + // The mesh sits on the node, or on its geometric-offset child. + m.nodes[n].mesh.or_else(|| m.nodes[n].children.iter().find_map(|&c| m.nodes[c].mesh)).unwrap() + }; + assert_eq!(mesh_of("Shape"), mesh_of("Copy"), "same geometry and materials: one mesh"); + assert_ne!(mesh_of("Shape"), mesh_of("Red only"), "different materials: its own mesh"); + assert_eq!(m.meshes.len(), 2); + // A budget with room for one copy of the geometry's mesh, but not three, still imports. + let one = parse(ASCII.as_bytes(), &|_| None).unwrap(); + let mesh = mesh_bytes(&one.meshes[0]).unwrap(); + let (_, doc) = read(instanced_scene().as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + 2 * mesh).is_ok()); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + mesh).is_err()); +} From fa6c9ffccd4fbb83fff943db7cdde86a7cea5e82 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 20/91] Web: IndexedDB puts and deletes resolve when their transaction commits (#1242) --- apps/effectcraft-web/js/host.js | 24 +++- apps/effectcraft-web/tests/storage.mjs | 180 +++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 apps/effectcraft-web/tests/storage.mjs diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 3db6ad35c..f113c2a2d 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -57,6 +57,26 @@ function req(r) { return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); } +// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has +// committed. A request's success doesn't mean the write is stored: the transaction can still +// abort afterwards (quota at commit, the browser closing), rolling it back, and a write +// reported as saved then is never retried (#1242). A failed request aborts the transaction +// with its error; an explicit abort leaves no error, hence the AbortError. +function write(db, store, op) { + return new Promise((res, rej) => { + let tx = null; + try { + tx = db.transaction(store, "readwrite"); + tx.oncomplete = () => res(); + tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError")); + op(tx.objectStore(store)); + } catch (e) { + try { tx?.abort(); } catch {} + rej(e); + } + }); +} + async function idb() { if (!self.indexedDB) throw new Error("no IndexedDB"); const open = indexedDB.open(DB, 1); @@ -70,10 +90,10 @@ async function idb() { return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified })); }, async put(key, data, modified) { - await req(tx("readwrite").put({ key, data: data.slice().buffer, modified })); + await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified })); }, async del(key) { - await req(tx("readwrite").delete(key)); + await write(db, STORE, (s) => s.delete(key)); }, }; } diff --git a/apps/effectcraft-web/tests/storage.mjs b/apps/effectcraft-web/tests/storage.mjs new file mode 100644 index 000000000..0855c1674 --- /dev/null +++ b/apps/effectcraft-web/tests/storage.mjs @@ -0,0 +1,180 @@ +// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no +// browser or Wasm build needed: +// +// node apps/effectcraft-web/tests/storage.mjs +// +// A write is acknowledged only once its transaction has committed, not when its request +// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be +// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure. +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const tick = () => new Promise((r) => setTimeout(r, 0)); +globalThis.self = globalThis; + +// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite +// transaction commits once its requests are done, unless it was aborted (which rolls back its +// writes). `fault` makes the next request do something else. +let fault = null; // "abortAfterSuccess" | "requestError" | "throw" +const databases = new Map(); + +class Request { + constructor(tx) { + this.transaction = tx; + this.result = undefined; + this.error = null; + } +} + +class Store { + constructor(tx, rows) { + this.tx = tx; + this.rows = rows; + } + request(apply) { + const tx = this.tx; + if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError"); + const f = fault; + fault = null; + if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError"); + const r = new Request(tx); + tx.pending++; + setTimeout(() => { + tx.pending--; + if (tx.finished) return; + if (f === "requestError") { + r.error = new DOMException("the key already exists", "ConstraintError"); + const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + r.onerror?.(e); + tx.onerror?.(e); + if (!e.defaultPrevented) tx.fail(r.error); + return; + } + r.result = apply(tx.staged); + r.onsuccess?.({ target: r }); + if (f === "abortAfterSuccess") tx.abort(); + tx.maybeCommit(); + }, 0); + return r; + } + put(v) { + return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; }); + } + delete(k) { + return this.request((rows) => { rows.delete(k); }); + } + get(k) { + return this.request((rows) => rows.get(k)); + } + getAll() { + return this.request((rows) => [...rows.values()]); + } +} + +class Transaction { + constructor(db, name) { + this.db = db; + this.name = name; + this.staged = new Map(db.stores.get(name)); + this.pending = 0; + this.finished = false; + this.error = null; + setTimeout(() => this.maybeCommit(), 0); + } + objectStore(name) { + const s = new Store(this, this.staged); + s.keyPath = this.db.keyPaths.get(name); + return s; + } + maybeCommit() { + if (this.finished || this.pending > 0) return; + this.finished = true; + this.db.stores.set(this.name, this.staged); + setTimeout(() => this.oncomplete?.(), 0); + } + fail(error) { + this.error = error; + this.abort(); + } + abort() { + if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError"); + this.finished = true; + setTimeout(() => this.onabort?.(), 0); + } +} + +class Database { + constructor() { + this.stores = new Map(); + this.keyPaths = new Map(); + } + createObjectStore(name, { keyPath }) { + this.stores.set(name, new Map()); + this.keyPaths.set(name, keyPath); + } + transaction(name) { + if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError"); + return new Transaction(this, name); + } +} + +globalThis.indexedDB = { + open(name) { + const r = new Request(null); + setTimeout(() => { + let db = databases.get(name); + if (!db) { + db = new Database(); + databases.set(name, db); + r.result = db; + r.onupgradeneeded?.(); + } + r.result = db; + r.onsuccess?.({ target: r }); + }, 0); + return r; + }, +}; + +const host = await import(pathToFileURL(join(here, "../js/host.js"))); +assert.equal(await host.storeOpen("indexeddb"), "indexeddb"); +const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key); +const bytes = (s) => new TextEncoder().encode(s); +const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data)); + +// A healthy write resolves once committed, and the store has it then. +await host.storePut("project.json", bytes("v1"), 1); +assert.equal(text(stored("project.json")), "v1", "a resolved put is committed"); + +// The put's request succeeds, then the transaction aborts: the put fails, the old value stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back"); + +// The same for a delete: it fails and the entry stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back"); + +// A failed request aborts its transaction with the request's error. +fault = "requestError"; +await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" }); +assert.equal(text(stored("project.json")), "v1"); + +// A request that throws at once (a value that can't be stored) fails the write. +fault = "throw"; +await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" }); +assert.equal(text(stored("project.json")), "v1"); + +// The retry after a failure commits the latest value, and a later delete removes it. +await host.storePut("project.json", bytes("v5"), 5); +assert.equal(text(stored("project.json")), "v5"); +await host.storeDelete("project.json"); +assert.equal(stored("project.json"), undefined, "a resolved delete is committed"); +const all = await host.storeLoadAll(); +assert.deepEqual(all, [], "nothing else was written"); + +await tick(); +console.log("storage: ok"); From f61f8c995846d5c641128cd5c46b7485e7e4e0c7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 21/91] Web: remembering and forgetting Media Browser folders waits for the commit too (#1242) --- apps/effectcraft-web/js/host.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index f113c2a2d..bab20f75a 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -570,7 +570,7 @@ async function openFolder(handle, remember) { if (remember) { try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle })); + await write(db, "folders", (s) => s.put({ name, handle })); } catch (e) { console.info("media browser: cannot remember the folder", e); } } return { name, entries }; @@ -622,7 +622,7 @@ export async function browseForget(name) { folders.delete(name); try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name)); + await write(db, "folders", (s) => s.delete(name)); } catch {} } From c03cd79b5910d69ec05155866275d75661cbf25a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 22/91] Docs: the IndexedDB storage test (#1242) --- docs/web.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/web.md b/docs/web.md index e73f7a899..7b5cd5c5b 100644 --- a/docs/web.md +++ b/docs/web.md @@ -432,6 +432,12 @@ a fake DOM: a picked file that can't be read is reported while the others are ad device-lost notice saves, flushes before reloading and reports a failed flush (`node apps/effectcraft-web/tests/page.mjs`). +`apps/effectcraft-web/tests/storage.mjs` checks the IndexedDB storage backend of `js/host.js` +with a fake IndexedDB: a put or delete resolves only once its transaction has committed, and one +whose transaction aborts after its request succeeded (or whose request fails or throws) rejects +and leaves the stored entry as it was, so the flush keeps it for a retry (#1242) +(`node apps/effectcraft-web/tests/storage.mjs`). + It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that replies when done while the page keeps running, and the Media Browser over browser storage; and From 1b44c5c98aac86b3e2d9f5d7b33b7125c292cc63 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 09:15:08 +0200 Subject: [PATCH 23/91] Scripting: Stroke and Hue/Saturation parameters use After Effects' match-name numbers (part of #1405) --- crates/script/src/matchnames.rs | 44 ++++++++++++++++++++---- crates/script/src/tests.rs | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 60a40e695..205697b60 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -399,7 +399,8 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { } /// Effects whose documented parameter match names are not numbered in display order: -/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. +/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. Params of +/// these effects that After Effects doesn't have are numbered after the listed ones (#1405). const PARAM_NUMBERS: &[(&str, &str, usize)] = &[ ("ec.generate.fill", "fillMask", 1), ("ec.generate.fill", "color", 2), @@ -408,8 +409,42 @@ const PARAM_NUMBERS: &[(&str, &str, usize)] = &[ ("ec.generate.fill", "opacity", 5), ("ec.generate.fill", "invert", 6), ("ec.generate.fill", "allMasks", 7), + ("ec.generate.stroke", "path", 1), + ("ec.generate.stroke", "color", 2), + ("ec.generate.stroke", "brushSize", 3), + ("ec.generate.stroke", "brushHardness", 4), + ("ec.generate.stroke", "opacity", 5), + ("ec.generate.stroke", "spacing", 6), + ("ec.generate.stroke", "paintStyle", 7), + ("ec.generate.stroke", "start", 8), + ("ec.generate.stroke", "end", 9), + ("ec.generate.stroke", "allMasks", 10), + ("ec.generate.stroke", "strokeSequentially", 11), + // -0001 is a hidden parameter and -0003 Channel Range, which we don't have; the per-channel + // controls are ours (After Effects edits the selected channel with the Master ones). + ("ec.color.huesaturation", "channelControl", 2), + ("ec.color.huesaturation", "hue", 4), + ("ec.color.huesaturation", "saturation", 5), + ("ec.color.huesaturation", "lightness", 6), + ("ec.color.huesaturation", "colorize", 7), + ("ec.color.huesaturation", "colorizeHue", 8), + ("ec.color.huesaturation", "colorizeSaturation", 9), + ("ec.color.huesaturation", "colorizeLightness", 10), ]; +/// The `-NNNN` number of the param `id` at 0-based `pos` among `fx`'s `params`. +fn param_number(fx: &str, params: &[Node], id: &str, pos: usize) -> usize { + let listed = |p: &str| PARAM_NUMBERS.iter().find(|(f, q, _)| *f == fx && *q == p).map(|(_, _, n)| *n); + if let Some(n) = listed(id) { + return n; + } + let Some(last) = PARAM_NUMBERS.iter().filter(|(f, _, _)| *f == fx).map(|(_, _, n)| *n).max() else { + return pos + 1; + }; + let unlisted_before = params.iter().take(pos).filter(|c| listed(c.match_id()).is_none()).count(); + last + unlisted_before + 1 +} + /// Match name of the node at the end of `chain` (layer root's children → … → node). `effect` /// params are numbered `-0001…` in parameter order, like After Effects. pub fn node(chain: &[&Node]) -> String { @@ -432,11 +467,8 @@ pub fn node(chain: &[&Node]) -> String { let fx = parent.and_then(Node::as_group); match fx { Some(g) => { - let i = PARAM_NUMBERS - .iter() - .find(|(fx, p, _)| *fx == g.match_id && *p == id) - .map(|(_, _, n)| *n) - .unwrap_or_else(|| g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1); + let pos = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0); + let i = param_number(&g.match_id, &g.children, id, pos); format!("{}-{i:04}", effect(&g.match_id)) } None => own(), diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 2c821e6ad..f47c212f0 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -126,6 +126,65 @@ fn fill_parameters_use_documented_match_names() { assert_eq!(o.result, json!("Color|All Masks|ADBE Fill-0007")); } +#[test] +fn stroke_parameters_use_after_effects_match_names() { + // #1405: `ADBE Stroke-0002` is Stroke's Color in After Effects, not All Masks. + let mut s = session(); + let o = ok( + &mut s, + r#"var c = app.project.items.addComp("S", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Stroke"), out = []; + for (var i = 1; i <= 11; i++) out.push(fx.property("ADBE Stroke-" + ("000" + i).slice(-4)).name); + out.push(fx.property("Color").matchName); out"#, + ); + assert_eq!( + o.result, + json!([ + "Path", + "Color", + "Brush Size", + "Brush Hardness", + "Opacity", + "Spacing", + "Paint Style", + "Start", + "End", + "All Masks", + "Stroke Sequentially", + "ADBE Stroke-0002" + ]) + ); +} + +#[test] +fn hue_saturation_parameters_use_after_effects_match_names() { + // #1405: `ADBE HUE SATURATION-0004` is Master Hue in After Effects. The per-channel controls, + // which After Effects doesn't expose, are numbered after its parameters so none takes an AE number. + let mut s = session(); + let o = ok( + &mut s, + r#"var c = app.project.items.addComp("H", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE HUE SATURATION"), out = []; + var n = [2, 4, 5, 6, 7, 8, 9, 10]; + for (var i = 0; i < n.length; i++) out.push(fx.property("ADBE HUE SATURATION-" + ("000" + n[i]).slice(-4)).name); + out.push(fx.property("Red Hue").matchName); out"#, + ); + assert_eq!( + o.result, + json!([ + "Channel Control", + "Master Hue", + "Master Saturation", + "Master Lightness", + "Colorize", + "Colorize Hue", + "Colorize Saturation", + "Colorize Lightness", + "ADBE HUE SATURATION-0011" + ]) + ); +} + #[test] fn undo_steps_without_a_group() { let mut s = session(); From 69d17d89afead1ce66d10429c661fb74be5571bc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:25:46 +0200 Subject: [PATCH 24/91] Keyframes: a path pasted onto Position becomes a motion path (fixes #1425) --- crates/engine/src/commands/anim.rs | 91 +++++++++++++++++++++++++++- crates/engine/src/commands/edit.rs | 7 +++ crates/engine/src/tests_menu_cmds.rs | 54 +++++++++++++++++ docs/target-app-parity.md | 3 + 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3ea27b0df..325845115 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -1,8 +1,8 @@ //! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key //! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs. -use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease}; -use effectcraft_project::{LayerId, Property, Uid}; +use effectcraft_keyframe::{Ease, Interp, Keyframe, ShapePath, Value as KValue, key_at, retime_roving, side_ease}; +use effectcraft_project::{ItemId, LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -61,6 +61,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if clip.is_empty() { return Err(bad("keys.paste", "no keyframes have been copied")); } + // A copied Mask Path / shape Path key pasted onto Position becomes a motion path (#1425). + if let [c] = clip.as_slice() + && let Some(KValue::Path(path)) = c.keys.first().map(|k| &k.value) + && let Some(target) = path_target(s, p)? + { + return paste_path(s, p, target, path); + } let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let t = time_p(s, p, Some(comp)); @@ -134,6 +141,86 @@ fn paste(s: &mut Session, p: &Value) -> Result { }) } +/// The spatial property (Position, Anchor Point…) a copied path pastes onto as a motion path: +/// the `prop` / `path` given, else a selected one of the target layers. `None` when there's none. +pub(super) fn path_target(s: &Session, p: &Value) -> Result> { + let (cid, targets) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let motion = |pr: &Property| pr.spatial && !pr.static_only && matches!(pr.value, KValue::Vec2(_) | KValue::Vec3(_)); + let explicit = targets.first().and_then(|lid| { + let l = comp.layer(*lid)?; + let pr = match p.get("prop").and_then(Value::as_u64) { + Some(u) => l.props.find(u), + None => l.props.prop(str_p(p, "path")?), + }?; + Some((*lid, pr)) + }); + if p.get("prop").is_some() || p.get("path").is_some() { + return Ok(explicit.filter(|(_, pr)| motion(pr)).map(|(lid, pr)| (cid, lid, pr.uid))); + } + let selected = s + .state + .selected_props + .iter() + .filter(|(lid, _)| targets.contains(lid)) + .find(|(lid, uid)| comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(motion)); + Ok(selected.map(|(lid, uid)| (cid, *lid, *uid))) +} + +/// Paste a mask or shape path onto a spatial property as After Effects does: a keyframe per +/// vertex with the path's tangents, from the CTI over 2 s (a closed path ends back on its first +/// vertex), the inner keys roving so the layer moves at one speed. Like After Effects, the +/// vertices are pasted as they are (layer-space numbers, not converted to the target's space). +pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, LayerId, Uid), path: &ShapePath) -> Result { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = time_p(s, p, Some(comp)); + let target = comp.layer(lid).ok_or(EngineError::NoComp)?; + let mut order: Vec = (0..path.vertices.len()).collect(); + if path.closed && !order.is_empty() { + order.push(0); + } + if order.is_empty() { + return Err(bad("keys.paste", "the copied path has no points")); + } + let span = Tick::from_seconds_f64(2.0).0 / i64::try_from(order.len().saturating_sub(1).max(1)).unwrap_or(i64::MAX); + let at = |xy: Option<&[f64; 2]>| { + let [x, y] = xy.copied().unwrap_or_default(); + [x, y, 0.0] + }; + let last = order.len() - 1; + let keys: Vec<(Tick, KValue, [f64; 3], [f64; 3], bool)> = order + .iter() + .enumerate() + .map(|(i, &v)| { + let [x, y, z] = at(path.vertices.get(v)); + let value = match target.props.find(uid).map(|pr| &pr.value) { + Some(KValue::Vec2(_)) => KValue::Vec2([x, y]), + _ => KValue::Vec3([x, y, z]), + }; + let time = target.layer_time(t + Tick(span.saturating_mul(i64::try_from(i).unwrap_or(i64::MAX)))); + (time, value, at(path.in_tangents.get(v)), at(path.out_tangents.get(v)), i != 0 && i != last) + }) + .collect(); + s.edit("Paste Keyframes", merge_p(p), |proj, st| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?; + let mut sel = vec![]; + for (time, value, spatial_in, spatial_out, roving) in keys { + let k = Keyframe { spatial_in, spatial_out, spatial_auto: false, roving, ..Keyframe::new(time, value) }; + match pr.keys.binary_search_by(|x| x.time.cmp(&time)) { + Ok(i) => pr.keys[i] = k, + Err(i) => pr.keys.insert(i, k), + } + sel.push(time); + } + // Re-timing keeps the keys' order, so the pasted keys are found again by index. + let pasted: Vec = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect(); + retime_roving(&mut pr.keys, pr.spatial); + st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect(); + Ok(json!(st.selected_keys.len())) + }) +} + /// Select every keyframe of the selected properties, or of the given / selected layers. fn select_all(s: &mut Session, p: &Value) -> Result { let (cid, mut layers) = layers_p(s, p)?; diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 06ba43b1c..10bb10d15 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -495,6 +495,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if let Some(clip) = s.state.link_clipboard.clone() { return paste_links(s, clip); } + // A copied Mask Path / shape Path pasted onto Position becomes a motion path (#1425). + if let [(_, effectcraft_keyframe::Value::Path(path))] = s.state.value_clipboard.as_slice() + && let Some(target) = super::anim::path_target(s, p)? + { + let path = path.clone(); + return super::anim::paste_path(s, p, target, &path); + } if !s.state.value_clipboard.is_empty() { return paste_properties(s, p); } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f6..6310e2520 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1396,6 +1396,60 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() assert_eq!(vals, vec![10.0, 0.0], "reversed"); } +/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion +/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI, +/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers +/// as they are, so the source layer's and the target parent's transforms don't move them. +#[test] +fn a_copied_path_pastes_onto_position_as_a_motion_path() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + // A moved and scaled source: converting to comp space would give 2v + (200, 100). + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap(); + let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]); + s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap(); + // B's parent would shift a converted Position by (0, −50). + let parent = solid(&mut s, "#0000ff"); + s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap(); + let b = solid(&mut s, "#00ff00"); + s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap(); + let motion = |s: &Session, id: u64| { + let l = layer(s, id); + let pr = l.props.prop("transform/position").unwrap().clone(); + pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::>() + }; + let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| { + let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]]; + assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}"); + for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() { + assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}"); + assert_eq!(*roving, i != 0 && i != 4, "key {i} roving"); + assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}"); + } + assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI"); + assert!(keys.windows(2).all(|w| w[0].0 < w[1].0)); + assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent"); + }; + // A Mask Path keyframe, pasted at 1 s. + s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, b), 1.0); + // The same path as an unanimated mask, pasted at 5 s onto an unparented layer. + s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let c = solid(&mut s, "#ffffff"); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, c), 5.0); +} + #[test] fn j_and_k_stop_at_keys_markers_and_the_work_area() { let mut s = comp(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 89464bcd8..2301fae7c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -746,6 +746,9 @@ them: scripts, ScriptUI panels, effect plug-ins, with a guide to writing a panel way and move them however slowly they are dragged; the same Shift / Ctrl / Ctrl+Alt clicks. - **Paste** (M5.13): keys copied from several layers paste onto as many layers in order; Paste Reversed Keyframes follows Paste's rules (stretch, target property, Hold-only properties). + A Mask Path or shape Path (its value or a key) pasted onto Position becomes a motion path: a + key per vertex with the path's tangents, over 2 s from the CTI, the inner keys roving, the + vertices pasted as they are (layer space, as in After Effects) (#1425). - **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected key's property, time and value. From 66a29505a580d91c7ee6b4eba123e3cc7c88a9fa Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Thu, 15 Oct 2026 00:19:47 +0900 Subject: [PATCH 25/91] i18n(ja): the Settings dialog in Japanese Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/ui.rs | 183 ++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..7ac3943de 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -631,6 +631,189 @@ const JAPANESE: &[(&str, &str)] = &[ ("After Effects project", "After Effectsプロジェクト"), ("Lottie animation", "Lottieアニメーション"), ("Project", "プロジェクト"), + ("General", "一般"), + ("Language", "言語"), + ("Levels of Undo", "取り消しレベル"), + ("Path Point and Handle Size", "パスポイントとハンドルサイズ"), + ("Recent Projects Shown", "最近使用したプロジェクトの表示数"), + ("Options", "オプション"), + ("Show Tool Tips", "ツールヒントを表示"), + ("Create Layers at Composition Start Time", "レイヤーをコンポジションの開始時間に作成"), + ("Switches Affect Nested Comps", "ネストしたコンポジションにスイッチを適用"), + ("Default Spatial Interpolation to Linear", "初期設定の空間補間法にリニアを使用"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "マスク編集時に一定の頂点数とぼかしポイント数を保持"), + ("Synchronize Time of All Related Items", "すべての関連アイテムの時間を同期"), + ("Expression Pick Whip Writes Compact English", "ピックウィップによる簡潔な英語でのエクスプレッションの記述"), + ("Create Split Layers Above Original Layer", "元のレイヤーの上に分割したレイヤーを作成"), + ("Use System Color Picker", "システムのカラーピッカーを使用"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraftは常に独自のカラーピッカーを使用します(すべてのプラットフォームとWeb版で同じです)。", + ), + ("Startup & Repair", "起動と修復"), + ("Show Home Screen When Launching", "起動時にホーム画面を表示"), + ("Show Home Screen When Opening a Project", "プロジェクトを開くときにホーム画面を表示"), + ("Offer to Open the Latest Auto-Save After a Crash", "クラッシュ後に最新の自動保存を開くか確認する"), + ("Window Graphics", "ウィンドウグラフィックス"), + ("Automatic", "自動"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "ウィンドウグラフィックスは次回の起動から適用されます。ウィンドウを開くときにグラフィックスドライバーがEffectCraftを停止させた場合、次回の起動ではOpenGLに切り替わります。", + ), + ("Repair", "修復"), + ("Reset Settings", "設定をリセット"), + ("Reset Keyboard Shortcuts", "キーボードショートカットをリセット"), + ("Empty Disk Cache", "ディスクキャッシュを空にする"), + ("New Project Loads Template", "新規プロジェクト読み込みテンプレート"), + ("Template Project", "テンプレートプロジェクト"), + ("Auto-Save", "自動保存"), + ("Automatically Save Projects", "プロジェクトを自動保存"), + ("Save Every", "保存の間隔"), + ("Minutes", "分"), + ("Maximum Project Versions", "プロジェクトバージョンの最大数"), + ("Auto-Save Location", "自動保存の場所"), + ("Next to Project", "プロジェクトの横"), + ("Custom Location", "ユーザー定義の場所"), + ("Save When Starting Render Queue", "レンダーキューの開始時に保存"), + ("Save Now", "今すぐ保存"), + ("Motion Path", "モーションパス"), + ("All Keyframes", "すべてのキーフレーム"), + ("No Motion Path", "モーションパスなし"), + ("No More Than (Seconds)", "次の秒数以下"), + ("No More Than (Keyframes)", "次のキーフレーム以下"), + ("Seconds", "秒"), + ("Keyframes", "キーフレーム"), + ("Display", "表示"), + ("Show Rendering Progress in Info Panel and Flowchart", "情報パネルとフローチャートにレンダリングの進行状況を表示"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "ハードウェアによるコンポジション、レイヤー、フッテージパネルの高速化"), + ("Panels are always drawn by the GPU (wgpu).", "パネルは常にGPU(wgpu)で描画されます。"), + ("Previews", "プレビュー"), + ("Adaptive Resolution Limit", "適応解像度の上限"), + ("Show Internal Wireframes", "内部ワイヤーフレームを表示"), + ("Cache Frames When Idle", "アイドル時にフレームをキャッシュ"), + ("Viewer Zoom Quality", "ズームの画質"), + ("Faster", "高速"), + ("More Accurate", "精度を優先"), + ("Display Color Space", "表示色空間"), + ("GPU Information...", "GPU 情報..."), + ("Appearance", "外観"), + ("Brightness", "明るさ"), + ("UI Scale", "UI スケール"), + ("Labels and Colors", "ラベルとカラー"), + ("Use Label Color for Layer Handles and Paths", "レイヤーハンドルとパスにラベルカラーを使用"), + ("Use Label Color for Related Tabs", "ラベルカラーを関連するタブに使用"), + ("Cycle Mask Colors", "新規マスクに別のカラーを使用"), + ("Use Gradients", "グラデーションを使用"), + ("Menu Bar", "メニューバー"), + ("Grids & Guides", "グリッド&ガイド"), + ("Gridline Every", "グリッドの間隔"), + ("Subdivisions", "分割数"), + ("Style", "スタイル"), + ("Lines", "線"), + ("Dashed Lines", "破線"), + ("Dots", "ドット"), + ("Horizontal", "水平方向"), + ("Vertical", "垂直方向"), + ("Safe Margins", "セーフマージン"), + ("Action-safe", "アクションセーフ"), + ("Title-safe", "タイトルセーフ"), + ("Labels", "ラベル"), + ("Text Engine", "テキストエンジン"), + ("Latin", "ラテン"), + ("South Asian and Middle Eastern", "南アジアおよび中東"), + ("Show Font Preview", "フォントのプレビューを表示"), + ("Number of Recent Fonts to Display", "最近使用したフォントの表示数"), + ("Show Font Names in English", "フォント名を英語で表示"), + ("Still Footage", "静止画フッテージ"), + ("Length of Composition", "コンポジションの長さ"), + ("Duration (Seconds)", "デュレーション(秒)"), + ("Still Duration", "静止画のデュレーション"), + ("Sequence Footage", "シーケンスフッテージ"), + ("Report Missing Frames", "不明なフレームをレポート"), + ("Automatically Reload Footage", "フッテージの自動再読み込み"), + ("Off", "オフ"), + ("Non-Sequence Footage", "非シーケンスフッテージ"), + ("All Footage", "すべてのフッテージ"), + ("Ask User", "ユーザーに確認"), + ("Guess", "推測"), + ("Ignore", "無視"), + ("Straight", "そのまま"), + ("Premultiplied", "プリマルチプライ"), + ("Default Drag Import As", "複数アイテムのドラッグ読み込みの初期設定"), + ("Footage", "フッテージ"), + ("Composition - Retain Layer Sizes", "コンポジション - レイヤーサイズを保持"), + ("Export", "書き出し"), + ("Default Output Folder", "既定の出力フォルダー"), + ("Segment Sequences", "シーケンスをセグメント化"), + ("Files per Segment", "セグメントあたりのファイル数"), + ("files", "ファイル"), + ("Segment Movie Files", "ムービーファイルをセグメント化"), + ("Segment Size", "セグメントサイズ"), + ("Append Bit Depth to File Name", "ファイル名にビット深度を追加"), + ("Audio", "オーディオ"), + ("Audio Hardware", "オーディオハードウェア"), + ("Preview Sample Rate", "サンプルレート"), + ("Audio Output Mapping", "オーディオ出力マッピング"), + ("Left", "左"), + ("Right", "右"), + ("channel", "チャンネル"), + ("Disk", "ディスク"), + ("Disk Cache", "ディスクキャッシュ"), + ("Enable Disk Cache", "ディスクキャッシュを有効にする"), + ("Maximum Disk Cache Size", "最大ディスクキャッシュサイズ"), + ("Disk Cache Folder", "ディスクキャッシュフォルダー"), + ("Media Cache", "メディアキャッシュ"), + ("Database and Cache Folder", "データベースとキャッシュのフォルダー"), + ("Conformed Audio Folder", "適合オーディオフォルダー"), + ("Memory & CPU", "メモリと CPU"), + ("RAM Reserved for Other Applications", "他のアプリケーション用に確保する RAM"), + ("Cache Budgets", "キャッシュの割り当て"), + ("Layer Cache", "レイヤーキャッシュ"), + ("Footage Frame Cache", "フッテージフレームキャッシュ"), + ("Preview (RAM) Cache", "プレビュー(RAM)キャッシュ"), + ("Reduce Cache Size When System Is Low on Memory", "システムのメモリが不足しているときにキャッシュサイズを減らす"), + ("Video", "ビデオ"), + ("Enable Video Preview Output", "ビデオプレビュー出力を有効にする"), + ("Video Device", "ビデオデバイス"), + ("Floating Window", "フローティングウィンドウ"), + ("Full Screen", "全画面"), + ("Video Output During Playback", "再生中のビデオ出力"), + ("Mirror on Computer Monitor", "コンピューターモニターにミラーリング"), + ("Disable Video Output When in Background", "バックグラウンド動作時のビデオ出力を無効にする"), + ("Default 3D Renderer", "既定の 3D レンダラー"), + ("Show 3D Reference Axes", "3D参照軸を表示"), + ("Extended Viewer", "拡張ビューアー"), + ("Realtime Shadows in Draft", "ドラフトのリアルタイムシャドウ"), + ("Scripting & Expressions", "スクリプトとエクスプレッション"), + ("Application Scripting", "アプリケーションのスクリプト"), + ("Allow Scripts to Write Files and Access Network", "スクリプトによるファイルへの書き込みとネットワークへのアクセスを許可"), + ("Warn User When Executing Files", "ファイルの実行時にカーソルに警告"), + ("Enable JavaScript Debugger", "JavaScript デバッガーを使用"), + ("Expressions Editor", "エクスプレッションエディター"), + ("Font Size", "フォントサイズ"), + ("Syntax Highlighting", "構文のハイライト"), + ("Line Numbers", "行番号を表示"), + ("Auto-complete", "オートコンプリート"), + ("Bracket Matching", "対応する括弧をハイライト"), + ("Word Wrap", "折り返し"), + ("Show Expression Error Banner", "式のエラーがある場合に警告バナーを表示"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "エクスプレッションはJavaScriptエンジンを使用します。スクリプトのエラーはファイルと行をスクリプトコンソールに報告します。ステップ実行デバッガーはありません。", + ), + ("Roto Brush", "ロトブラシ"), + ("Segmentation Model", "セグメンテーションモデル"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "ロトブラシ2.0と3.0はここで選んだモデルを使用します(バージョン1.0は常に従来のエンジンを使用します)。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。", + ), + ("Face Tracking", "フェイストラッキング"), + ("Face Model", "フェイスモデル"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "トラッカーパネルのフェイストラッキング(輪郭のみと詳細機能)はここで選んだモデルを使用します。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。", + ), + ("Match System", "システムに合わせる"), ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), From eb95f732e5ae2aabda0443ec941173dc2960d9bc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:33:49 -0400 Subject: [PATCH 26/91] Media: millisecond-rounded MP4 frame times read at the rate they average to (#1442) Phones and messaging apps store frame times rounded to whole milliseconds (33/34 ms at 30 fps, 16/17 ms at 60 fps). FilmCraft's reader takes the median duration, so such clips imported at 30.303 or 58.824 fps, and New Comp from Selection and renders followed that rate. When the median is no standard rate, the durations near it are averaged and snapped to the standard rate within 0.5 %; exact and variable rates keep the reader's estimate. --- crates/media/src/probe.rs | 54 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index bae714c81..dad34fd13 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -19,8 +19,10 @@ pub fn probe(path: impl AsRef) -> Result { // A movie is read in place (its index), not loaded whole. if let Some(src) = crate::stream::probe_source(path) { let mut f = footage_from_info(&path.to_string_lossy(), src?.info()); - let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h)); + let header = crate::stream::movie_header(path); + let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn); turn_footage(&mut f, turn); + correct_rate(&mut f, header.as_deref().and_then(video_rate)); return Ok(f); } let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; @@ -95,12 +97,24 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { } let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); + let rate = video_rate(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); turn_footage(&mut f, turn); + correct_rate(&mut f, rate); Ok(f) } +/// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's +/// own estimate misreads it. +fn correct_rate(f: &mut Footage, rate: Option) { + if f.kind == FootageKind::Video + && let Some(rate) = rate + { + f.frame_rate = rate; + } +} + /// The size as displayed: a movie shot with the camera on its side trades width and height. fn turn_footage(f: &mut Footage, turn: Orientation) { if swaps_sides(turn) { @@ -254,3 +268,41 @@ pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { (false, _, false) => Orientation::Rotate270, } } + +/// The frame rate of an MP4/MOV movie's video track when the reader's own estimate, the median +/// frame duration, misreads it. Phones and messaging apps store frame times rounded to whole +/// milliseconds: a 30 fps clip's frames last 33 or 34 ms and a 60 fps clip's 16 or 17 ms, whose +/// median reads as 30.303 or 58.824 fps. `None` for other containers and for movies the median +/// reads right. +fn video_rate(bytes: &[u8]) -> Option { + let file = filmcraft_isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + averaged_rate(t.timescale, t.samples.iter().take(240).map(|s| s.duration)) +} + +/// The standard rate ([`FrameRate::COMMON`]) within 0.5 % of what the frame durations near their +/// median average to (`durations` in `1 / timescale` s); `None` when the median duration is a +/// standard rate already, or when the average is no standard rate (variable or unusual rates keep +/// the reader's estimate). +fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option { + let ts = i128::from(timescale); + let mut durs: Vec = durations.filter(|&d| d > 0).map(u64::from).collect(); + durs.sort_unstable(); + let median = *durs.get(durs.len() / 2)?; + if FrameRate::COMMON.iter().any(|r| ts * i128::from(r.den) == i128::from(median) * i128::from(r.num)) { + return None; + } + // Durations within a quarter of the median (widened, so extreme durations can't overflow). + let (n, sum) = durs.iter().filter(|&&d| d.abs_diff(median).saturating_mul(4) <= median).fold((0u64, 0u64), |(n, s), &d| (n + 1, s.saturating_add(d))); + if sum == 0 { + return None; + } + let fps = timescale as f64 * n as f64 / sum as f64; + FrameRate::COMMON + .into_iter() + .map(|r| (r, (fps / r.as_f64() - 1.0).abs())) + .filter(|&(_, off)| off <= 0.005) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(r, _)| r) +} + From 36a905293b65d4487528456cf6d308e89fd7283d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:33:49 -0400 Subject: [PATCH 27/91] Media: tests for the averaged MP4 frame rate (#1442) --- crates/media/src/probe.rs | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index dad34fd13..2793be251 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -306,3 +306,48 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option .map(|(r, _)| r) } +#[cfg(test)] +mod tests { + use super::*; + + /// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale. + fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec { + let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num); + (0..n).map(|k| (at(k + 1) - at(k)) as u32).collect() + } + + #[test] + fn millisecond_rounded_frame_times_average_to_their_rate() { + assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]); + assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_30, 1000, 120).into_iter()), Some(FrameRate::FPS_30)); + assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_60, 1000, 240).into_iter()), Some(FrameRate::FPS_60)); + // NTSC rates stay NTSC, not the round rate next to them. + for r in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] { + assert_eq!(averaged_rate(1000, rounded(r, 1000, 240).into_iter()), Some(r), "{r:?}"); + } + // A short clip: its few frames average to the rate as well. + assert_eq!(averaged_rate(1000, [33, 34, 33].into_iter()), Some(FrameRate::FPS_30)); + } + + #[test] + fn exact_and_variable_rates_keep_the_readers_estimate() { + // A median that is a standard rate already reads right. + assert_eq!(averaged_rate(30000, rounded(FrameRate::FPS_29_97, 30000, 240).into_iter()), None); + assert_eq!(averaged_rate(90000, [3000; 10].into_iter()), None); + assert_eq!(averaged_rate(600, [20, 20, 19, 21, 20].into_iter()), None); + // Durations averaging to no standard rate (22.2 fps). + assert_eq!(averaged_rate(1000, [40, 45, 50].repeat(20).into_iter()), None); + // A truly 30.3 fps clip is not pulled to 30 (1 % off). + assert_eq!(averaged_rate(1000, [33; 30].into_iter()), None); + } + + #[test] + fn degenerate_durations_have_no_rate() { + assert_eq!(averaged_rate(1000, std::iter::empty()), None); + assert_eq!(averaged_rate(1000, [0, 0, 0].into_iter()), None); + assert_eq!(averaged_rate(0, [33, 34, 33].into_iter()), None); + assert_eq!(averaged_rate(u32::MAX, [u32::MAX, u32::MAX - 1, 1, 0].into_iter()), None); + assert_eq!(averaged_rate(u32::MAX, [u32::MAX; 300].into_iter()), None); + assert_eq!(video_rate(b"not a movie"), None); + } +} From 8b536e597bab07116fb2776f23473549e868e329 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:37:36 -0400 Subject: [PATCH 28/91] Export: millisecond-rounded footage renders at its rate, each frame once (#1442) --- crates/export/tests/ms_rounded_footage.rs | 148 ++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 crates/export/tests/ms_rounded_footage.rs diff --git a/crates/export/tests/ms_rounded_footage.rs b/crates/export/tests/ms_rounded_footage.rs new file mode 100644 index 000000000..3d6880232 --- /dev/null +++ b/crates/export/tests/ms_rounded_footage.rs @@ -0,0 +1,148 @@ +//! Footage whose frame times are rounded to whole milliseconds (#1442), as phones and messaging +//! apps write MP4s: a 30 fps clip's frames last 33 or 34 ms, a 60 fps clip's 16 or 17 ms. It +//! imports at its true rate, a comp made from it (New Comp from Selection takes the footage's +//! rate) renders every source frame once, and the H.264 output has that rate and frame count. +//! +//! The fixtures are written here with FilmCraft's own encoder and muxer: 128×32 frames showing +//! their index in binary (eight bars, white for a set bit). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::{Job, export}; +use effectcraft_media::MediaPool; +use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_time::{FrameRate, Tick}; +use filmcraft_isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + +const W: u32 = 128; +const H: u32 = 32; +const BARS: u32 = 8; + +fn out_dir(name: &str) -> PathBuf { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("mkdir"); + d +} + +/// Durations of `n` frames at `rate` with their times rounded to whole milliseconds. +fn ms_rounded(rate: FrameRate, n: i64) -> Vec { + let at = |k: i64| (2 * k * 1000 * rate.den + rate.num) / (2 * rate.num); + (0..n).map(|k| u32::try_from(at(k + 1) - at(k)).expect("duration")).collect() +} + +/// An H.264 MP4 (video track timescale 1000) with one frame per entry of `durations` (ms); +/// frame k shows k in binary. +fn write_movie(path: &Path, durations: &[u32]) { + let mut cfg = filmcraft_h264enc::EncoderConfig::new(W, H, 30, 1); + cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed; + cfg.aud = false; + cfg.bframes = 0; + cfg.keyint = 30; + cfg.scenecut = false; + let mut enc = filmcraft_h264enc::Encoder::new(cfg).expect("encoder"); + let (u, v) = (vec![128u8; (W * H / 4) as usize], vec![128u8; (W * H / 4) as usize]); + let mut packets = Vec::new(); + for k in 0..durations.len() { + let y: Vec = (0..W * H).map(|i| if (k >> (i % W * BARS / W)) & 1 == 1 { 235 } else { 16 }).collect(); + let frame = filmcraft_h264enc::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 }; + packets.extend(enc.try_encode(&frame, k as i64).expect("encode")); + } + packets.extend(enc.flush()); + assert_eq!(packets.len(), durations.len(), "one access unit per frame"); + let avcc = AvcConfig::parse(&enc.avcc()).expect("avcC"); + let file = std::fs::File::create(path).expect("create"); + let mut mux = Mp4Writer::new(file, WriterOptions::new(Brand::Mp4)).expect("mux"); + let t = mux.add_track(TrackConfig::new(SampleEntry::avc(avcc, W as u16, H as u16), 1000)).expect("track"); + for (p, &duration) in packets.iter().zip(durations) { + mux.write_sample(t, WriteSample { data: &p.data, duration, composition_offset: 0, is_sync: p.keyframe }).expect("sample"); + } + mux.finish().expect("finish"); +} + +/// The frame index a decoded frame shows. +fn shown(img: &Image) -> u32 { + (0..BARS).filter(|b| img.data[img.idx(b * W / BARS + W / BARS / 2, H / 2)][1] > 0.5).map(|b| 1 << b).sum() +} + +fn frame_count(f: &Footage) -> i64 { + f.frame_rate.frame_at(f.duration - Tick(1)) + 1 +} + +/// Every frame of a movie in order, as indices. +fn frames_of(f: &Footage) -> Vec { + let pool = MediaPool::new(); + (0..frame_count(f)).map(|k| shown(&pool.frame_at(f, f.frame_rate.tick_of(k)).expect("decode"))).collect() +} + +/// Import `path`, make a comp from it as New Comp from Selection does (the footage's size, rate +/// and duration), render it to H.264 and return the output's footage. +fn comp_from_footage(path: &Path, out: &Path) -> Footage { + let footage = effectcraft_media::probe(path).expect("probe"); + let mut p = Project::default(); + let fid = p.add_item("clip.mp4", Label::Aqua, None, ItemKind::Footage(footage.clone())); + let rate = p.item(fid).and_then(|it| it.frame_rate()).expect("a movie has a rate"); + let mut comp = Comp::new(W, H, rate, footage.duration); + comp.layers.push(build::layer(&mut p, &comp, "clip.mp4", LayerSource::Footage { item: fid }, (W, H), None)); + let cid = p.add_item("clip", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let pool = MediaPool::new(); + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) }; + let out_path = out.to_string_lossy().to_string(); + let job = Job { + project: &p, + footage: &pool, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: &out_path, + sink: None, + options: Default::default(), + nested_switches: true, + }; + let r = export(&job, &mut |_| true).expect("export"); + assert_eq!(r.frames as i64, frame_count(&footage), "the render has the footage's frames"); + effectcraft_media::probe(out).expect("probe the render") +} + +#[test] +fn millisecond_rounded_footage_keeps_its_rate_through_comp_and_render() { + let d = out_dir("ms_rounded"); + for (rate, frames) in [(FrameRate::FPS_30, 120), (FrameRate::FPS_60, 240)] { + let path = d.join(format!("ms-{}.mp4", rate.num)); + write_movie(&path, &ms_rounded(rate, frames)); + let f = effectcraft_media::probe(&path).expect("probe"); + assert_eq!((f.frame_rate, frame_count(&f)), (rate, frames), "{rate:?} footage"); + let bytes: Arc<[u8]> = std::fs::read(&path).expect("read").into(); + let from_bytes = effectcraft_media::probe_bytes(&path.to_string_lossy(), bytes).expect("probe bytes"); + assert_eq!(from_bytes.frame_rate, rate, "{rate:?} footage read from memory"); + let all: Vec = (0..frames as u32).collect(); + assert_eq!(frames_of(&f), all, "{rate:?} footage: each source frame once"); + + let out = comp_from_footage(&path, &d.join(format!("render-{}.mp4", rate.num))); + assert_eq!((out.frame_rate, frame_count(&out)), (rate, frames), "{rate:?} render"); + assert_eq!(out.duration, Tick::from_seconds_f64(4.0), "{rate:?} render duration"); + assert_eq!(frames_of(&out), all, "{rate:?} render: no frame skipped or repeated"); + } +} + +#[test] +fn ntsc_and_variable_rate_footage_is_not_pulled_to_a_round_rate() { + let d = out_dir("ms_rounded_controls"); + for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] { + let path = d.join(format!("ms-{}.mp4", rate.num)); + write_movie(&path, &ms_rounded(rate, 60)); + assert_eq!(effectcraft_media::probe(&path).expect("probe").frame_rate, rate); + } + // Frames of 40, 45 and 50 ms: the reader's median (45 ms, 22.2 fps) stands. + let path = d.join("variable.mp4"); + write_movie(&path, &[40, 45, 50].repeat(20)); + let f = effectcraft_media::probe(&path).expect("probe"); + assert!(!FrameRate::COMMON.contains(&f.frame_rate) && (f.frame_rate.as_f64() - 1000.0 / 45.0).abs() < 0.01, "{:?}", f.frame_rate); +} From 2f437ea551de3b8f512d28f10a46fe58a509ed78 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 09:38:47 -0700 Subject: [PATCH 29/91] EPS and PDF: huge arcs, long paths and loop passes count against the import's limits The EPS interpreter counted one operation against its budget for each name it ran, whatever the operator did, and paths had no length limit. Parsing one arc at a radius of 10^300 passed 3 GiB of resident memory within 43 seconds. A loop of arc at a radius of 2.2 x 10^18 was still running after 60 seconds, and a loop of pathbbox on a path of 100,001 elements and { {} loop } loop did not finish within 20 seconds. PDF content had no path limit either: parsing a page that repeats 0 0 1 1 re 4,000,000 times peaked at 2.3 GiB of resident memory. A path now holds at most 1,048,576 elements (MAX_PATH, the limit of VectorCraft's EPS interpreter). In EPS, moveto, lineto, curveto, closepath, arc, charpath and the rect operators check it, and an operator that would take a path past it stops the program with "PostScript (path limit)". In PDF, m, l, c, v, y, h and re (each adds at most five elements) stop the content stream with "content (path limit)" when the path already holds more than 1,048,571. Each path element an operator makes, reads or copies (arc, rectangle arrays, charpath, pathbbox, clip, eoclip, gsave, save) now counts against the EPS budget. Each pass of repeat, for, loop and forall counts too, and so does each procedure item that is not a name. A moveto right after a moveto replaces it, as the PostScript Language Reference describes, so text placed with moveto and show leaves one element. Five tests in tests_import.rs cover the loops, the path limit in both interpreters and runs of moveto and show. --- crates/pdf/src/build.rs | 4 + crates/pdf/src/page.rs | 9 +- crates/pdf/src/ps.rs | 174 +++++++++++++++++++++++---------- crates/pdf/src/tests_import.rs | 94 ++++++++++++++++++ 4 files changed, 228 insertions(+), 53 deletions(-) diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 7023e931c..0b1586f89 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -3,6 +3,10 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke}; +/// The most elements a path being built holds: an operator that would add more stops the +/// PostScript program, or the PDF content stream. +pub(crate) const MAX_PATH: usize = 1 << 20; + #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Plain, diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 13e98b3c9..1cf620f14 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -12,7 +12,7 @@ use effectcraft_svg::{ }; use kurbo::{Point, Shape as _}; -use crate::build::Builder; +use crate::build::{Builder, MAX_PATH}; use crate::color::{Cs, Func, color_space, text_string}; use crate::font::Font; use crate::object::{Dict, File, Lexer, Obj, decode_stream}; @@ -281,7 +281,12 @@ impl<'a> Interp<'a> { self.ext_gstate(&g, res, depth); } } - // Path construction. + // Path construction. Each operator adds at most five elements (`re`), so when fewer + // than five more fit within `MAX_PATH`, the operator stops the stream. + "m" | "l" | "c" | "v" | "y" | "h" | "re" if self.path.elements().len() > MAX_PATH - 5 => { + self.b.skip("content (path limit)"); + return; + } "m" => { let p = Point::new(n(0), n(1)); self.path.move_to(p); diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 65e352cb2..93ed55e74 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -14,7 +14,7 @@ use std::rc::Rc; use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke}; use kurbo::{PathEl, Point}; -use crate::build::Builder; +use crate::build::{Builder, MAX_PATH}; use crate::font::Font; use crate::object::{ascii85, is_white, parse_num}; @@ -441,7 +441,14 @@ impl Interp { break; } } - other => self.push(other.clone()), + // Other items count against the budget as names do. + other => { + r = self.spend(1); + if r.is_err() { + break; + } + self.push(other.clone()); + } } } self.calls -= 1; @@ -456,11 +463,7 @@ impl Interp { } fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { - if self.budget == 0 { - self.b.skip("PostScript (operation limit)"); - return Err(Flow::Quit); - } - self.budget -= 1; + self.spend(1)?; if let Some(v) = self.lookup(n) { return match v { V::Proc(_) => self.exec_obj(v, src), @@ -473,6 +476,21 @@ impl Interp { self.builtin(n, src) } + /// Count `n` operations against the budget, and stop the program when fewer are left. An + /// operator that makes, reads or copies path elements counts each one as an operation. + fn spend(&mut self, n: usize) -> Result<(), Flow> { + match self.budget.checked_sub(n) { + Some(left) => { + self.budget = left; + Ok(()) + } + None => { + self.b.skip("PostScript (operation limit)"); + Err(Flow::Quit) + } + } + } + fn device_scale(&self) -> f64 { self.gs.ctm.determinant().abs().sqrt() } @@ -483,13 +501,34 @@ impl Interp { Point::new(x, y) } - fn move_to(&mut self, p: Point) { + /// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`]. + fn path_fits(&mut self, len: usize) -> Result<(), Flow> { + if len <= MAX_PATH { + return Ok(()); + } + self.b.skip("PostScript (path limit)"); + Err(Flow::Quit) + } + + /// [`Self::path_fits`] for the current path with `n` more elements. + fn room(&mut self, n: usize) -> Result<(), Flow> { + self.path_fits(self.gs.path.elements().len().saturating_add(n)) + } + + fn move_to(&mut self, p: Point) -> Result<(), Flow> { + // A moveto right after a moveto replaces it (PostScript Language Reference §8.2, `moveto`). + if matches!(self.gs.path.elements().last(), Some(PathEl::MoveTo(_))) { + self.gs.path.pop(); + } + self.room(1)?; let q = self.gs.ctm * p; self.gs.path.move_to(q); self.gs.cur = Some(q); self.gs.start = Some(q); + Ok(()) } - fn line_to(&mut self, p: Point) { + fn line_to(&mut self, p: Point) -> Result<(), Flow> { + self.room(1 + usize::from(self.gs.cur.is_none()))?; let q = self.gs.ctm * p; if self.gs.cur.is_none() { self.gs.path.move_to(q); @@ -497,20 +536,23 @@ impl Interp { } self.gs.path.line_to(q); self.gs.cur = Some(q); + Ok(()) } - fn curve_to(&mut self, a: Point, b: Point, c: Point) { + fn curve_to(&mut self, a: Point, b: Point, c: Point) -> Result<(), Flow> { + self.room(1 + usize::from(self.gs.cur.is_none()))?; let m = self.gs.ctm; if self.gs.cur.is_none() { self.gs.path.move_to(m * a); } self.gs.path.curve_to(m * a, m * b, m * c); self.gs.cur = Some(m * c); + Ok(()) } fn current_user(&self) -> Point { self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default() } - fn arc(&mut self, neg: bool) { + fn arc(&mut self, neg: bool) -> Result<(), Flow> { let a2 = self.popn(); let a1 = self.popn(); let r = self.popn(); @@ -540,16 +582,23 @@ impl Interp { let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians()); let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()); if self.gs.cur.is_some() { - self.line_to(start); + self.line_to(start)?; } else { - self.move_to(start); + self.move_to(start)?; } let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0); let m = self.gs.ctm; - arc.to_cubic_beziers(0.1, |p1, p2, p3| { + // kurbo divides an arc into more curves the larger the radius (about 3.7 × 10^49 for a + // quarter turn at a radius of 10^300, more than a usize can count), so each one counts + // against the budget and the path limit. + let mut curves = arc.append_iter(0.1); + while let Some(PathEl::CurveTo(p1, p2, p3)) = curves.next() { + self.room(1)?; + self.spend(1)?; self.gs.path.curve_to(m * p1, m * p2, m * p3); self.gs.cur = Some(m * p3); - }); + } + Ok(()) } fn paint(&mut self, fill: Option, stroke: bool) { @@ -569,11 +618,16 @@ impl Interp { self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st); } - fn rect_path(&mut self) -> Vec { + fn rect_path(&mut self) -> Result, Flow> { // x y w h, or an array / string of numbers. let top = self.pop(); let nums: Vec = match &top { - V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(), + V::Array(_) | V::Proc(_) => { + let items = top.items().unwrap_or_default(); + // An array's rectangles count against the budget, five path elements each. + self.spend(items.len() / 4 * 5)?; + items.iter().filter_map(V::num).collect() + } V::Num(h) => { let w = self.popn(); let y = self.popn(); @@ -582,8 +636,10 @@ impl Interp { } _ => vec![], }; + self.path_fits(nums.len() / 4 * 5)?; let m = self.gs.ctm; - nums.as_chunks::<4>() + Ok(nums + .as_chunks::<4>() .0 .iter() .map(|r| { @@ -595,7 +651,7 @@ impl Interp { p.close_path(); p }) - .collect() + .collect()) } fn grestore(&mut self) { @@ -809,10 +865,12 @@ impl Interp { let c = matches!(self.pop(), V::Bool(true)); self.exec_obj(if c { t } else { f }, src)?; } + // Each pass of a loop counts against the budget. "repeat" => { let p = self.pop(); let k = self.popn().clamp(0.0, 1e6) as usize; for _ in 0..k { + self.spend(1)?; match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, r => r?, @@ -826,6 +884,7 @@ impl Interp { let mut i = self.popn(); let mut guard = 0; while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { + self.spend(1)?; self.push(V::Num(i)); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -838,6 +897,7 @@ impl Interp { "loop" => { let p = self.pop(); for _ in 0..1_000_000 { + self.spend(1)?; match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, r => r?, @@ -850,6 +910,7 @@ impl Interp { V::Dict(d) => { let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); for (k, v) in items { + self.spend(1)?; self.push(V::Lit(k.into())); self.push(v); match self.exec_obj(p.clone(), src) { @@ -861,6 +922,7 @@ impl Interp { V::Str(s) => { let bytes = s.borrow().clone(); for c in bytes { + self.spend(1)?; self.push(V::Num(c as f64)); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -870,6 +932,7 @@ impl Interp { } a => { for v in a.items().unwrap_or_default() { + self.spend(1)?; self.push(v); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -1127,6 +1190,7 @@ impl Interp { "languagelevel" => self.push(V::Num(3.0)), "version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))), "save" => { + self.spend(self.gs.path.elements().len())?; self.saved.push(self.gs.clone()); self.gs.depth = self.b.depth(); self.push(V::Null); @@ -1156,6 +1220,7 @@ impl Interp { } // Graphics state. "gsave" => { + self.spend(self.gs.path.elements().len())?; let mut g = self.gs.clone(); g.depth = self.b.depth(); self.saved.push(g); @@ -1316,21 +1381,21 @@ impl Interp { } "moveto" => { let p = self.user_point(); - self.move_to(p); + self.move_to(p)?; } "rmoveto" => { let d = self.user_point(); let c = self.current_user(); - self.move_to(Point::new(c.x + d.x, c.y + d.y)); + self.move_to(Point::new(c.x + d.x, c.y + d.y))?; } "lineto" => { let p = self.user_point(); - self.line_to(p); + self.line_to(p)?; } "rlineto" => { let d = self.user_point(); let c = self.current_user(); - self.line_to(Point::new(c.x + d.x, c.y + d.y)); + self.line_to(Point::new(c.x + d.x, c.y + d.y))?; } "curveto" | "rcurveto" => { let p3 = self.user_point(); @@ -1339,25 +1404,27 @@ impl Interp { if n == "rcurveto" { let c = self.current_user(); let o = |p: Point| Point::new(c.x + p.x, c.y + p.y); - self.curve_to(o(p1), o(p2), o(p3)); + self.curve_to(o(p1), o(p2), o(p3))?; } else { - self.curve_to(p1, p2, p3); + self.curve_to(p1, p2, p3)?; } } "closepath" => { if self.gs.cur.is_some() { + self.room(1)?; self.gs.path.close_path(); self.gs.cur = self.gs.start; } } - "arc" => self.arc(false), - "arcn" => self.arc(true), + "arc" => self.arc(false)?, + "arcn" => self.arc(true)?, "currentpoint" => { let c = self.current_user(); self.push(V::Num(c.x)); self.push(V::Num(c.y)); } "pathbbox" => { + self.spend(self.gs.path.elements().len())?; let r = kurbo::Shape::bounding_box(&self.gs.path); let inv = self.gs.ctm.inverse(); let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); @@ -1376,13 +1443,14 @@ impl Interp { "stroke" => self.paint(None, true), "clip" | "eoclip" => { let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd }; + self.spend(self.gs.path.elements().len())?; let p = self.gs.path.clone(); if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) { self.b.clip(p, rule); } } "rectfill" | "rectstroke" | "rectclip" => { - let paths = self.rect_path(); + let paths = self.rect_path()?; if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) { self.pop(); } @@ -1617,9 +1685,9 @@ impl Interp { /// A string's glyph outlines (device space) from the current point, with an extra /// displacement `extra(index, code)` (user space) after each glyph; moves the current /// point. - fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> { - let d = self.gs.font.clone()?; - let (font, m) = self.font_of(&d)?; + fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result, Flow> { + let Some(d) = self.gs.font.clone() else { return Ok(None) }; + let Some((font, m)) = self.font_of(&d) else { return Ok(None) }; let mut p = self.current_user(); let mut path = BezPath::new(); let mut text = String::new(); @@ -1634,8 +1702,8 @@ impl Interp { let (ex, ey) = extra(i, code); p += adv + kurbo::Vec2::new(ex, ey); } - self.move_to(p); - Some((path, text)) + self.move_to(p)?; + Ok(Some((path, text))) } fn fill_text(&mut self, path: BezPath, text: &str) { @@ -1651,11 +1719,12 @@ impl Interp { } } - fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) { - match self.text_path(s, extra) { + fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<(), Flow> { + match self.text_path(s, extra)? { Some((path, text)) => self.fill_text(path, &text), None => self.b.skip("text (no font)"), } + Ok(()) } /// Text and font operators (PostScript Language Reference §5.1, §8.2). @@ -1713,20 +1782,20 @@ impl Interp { "StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)), "show" => { let s = self.pop_string(); - self.show_with(&s, |_, _| (0.0, 0.0)); + self.show_with(&s, |_, _| (0.0, 0.0))?; } "ashow" => { let s = self.pop_string(); let ay = self.popn(); let ax = self.popn(); - self.show_with(&s, |_, _| (ax, ay)); + self.show_with(&s, |_, _| (ax, ay))?; } "widthshow" => { let s = self.pop_string(); let ch = self.popn() as u32; let cy = self.popn(); let cx = self.popn(); - self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) }); + self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) })?; } "awidthshow" => { let s = self.pop_string(); @@ -1735,18 +1804,18 @@ impl Interp { let ch = self.popn() as u32; let cy = self.popn(); let cx = self.popn(); - self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) }); + self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) })?; } "kshow" | "cshow" => { // The procedure between characters is not run. let s = self.pop_string(); self.pop(); - self.show_with(&s, |_, _| (0.0, 0.0)); + self.show_with(&s, |_, _| (0.0, 0.0))?; } "xshow" | "yshow" | "xyshow" => { let disp: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); let s = self.pop_string(); - self.explicit_show(&s, &disp, n); + self.explicit_show(&s, &disp, n)?; } "glyphshow" => { let name = self.pop().key(); @@ -1757,7 +1826,7 @@ impl Interp { let tmp = transformed(&d, Affine::IDENTITY); tmp.borrow_mut().insert("Encoding".into(), array(e)); self.gs.font = Some(tmp); - self.show_with(b"A", |_, _| (0.0, 0.0)); + self.show_with(b"A", |_, _| (0.0, 0.0))?; self.gs.font = Some(d); } "charpath" => { @@ -1765,14 +1834,17 @@ impl Interp { let s = self.pop_string(); let saved = std::mem::take(&mut self.gs.path); let start = self.gs.start; - match self.text_path(&s, |_, _| (0.0, 0.0)) { + match self.text_path(&s, |_, _| (0.0, 0.0))? { Some((glyphs, _)) => { - let mut p = saved; - p.extend(glyphs); + // The outlines join the path: each element counts against the budget and + // the path limit. + self.gs.path = saved; + self.room(glyphs.elements().len() + 1)?; + self.spend(glyphs.elements().len())?; + self.gs.path.extend(glyphs); if let Some(c) = self.gs.cur { - p.move_to(c); + self.gs.path.move_to(c); } - self.gs.path = p; self.gs.start = start; } None => self.gs.path = saved, @@ -1822,7 +1894,7 @@ impl Interp { /// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the /// advances. - fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) { + fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) -> Result<(), Flow> { let step = if n == "xyshow" { 2 } else { 1 }; let d: Vec<(f64, f64)> = (0..s.len()) .map(|k| { @@ -1837,13 +1909,13 @@ impl Interp { .collect(); let Some(fd) = self.gs.font.clone() else { self.b.skip("text (no font)"); - return; + return Ok(()); }; - let Some((font, m)) = self.font_of(&fd) else { return }; + let Some((font, m)) = self.font_of(&fd) else { return Ok(()) }; self.show_with(s, move |k, c| { let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO; let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0)); (dx - adv.x, dy - adv.y) - }); + }) } } diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 532e32cb9..de49ec408 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -359,6 +359,100 @@ fn arcs_with_huge_angles_end_at_their_second_angle() { assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); } +/// Parses `body` after a square through [`eps_soon`]: the square stays, and the program stops at +/// the operation limit. +fn runs_out_of_budget(body: &str) { + let doc = eps_soon(&format!("0 0 10 10 rectfill {body}")); + assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["PostScript (operation limit)"], "{body}"); +} + +#[test] +fn paths_made_in_a_loop_run_out_of_the_budget() { + // Each curve, rectangle element and glyph outline element an operator makes counts against the + // operation budget: `arc` at a radius of 2.2 × 10^18 makes about 6,800 curves a call, + // `rectclip` with an array of 2,000 rectangles makes 10,000 elements, and `charpath` makes the + // outlines of 1,000 H's. + for body in [ + "{ newpath 0 0 2200000000000000000 0 1440 arc } loop", + "/a 8000 array def 0 1 7999 { a exch 0 put } for { gsave a rectclip grestore } loop", + "/Helvetica findfont 10 scalefont setfont /s 1000 string def 0 1 999 { s exch 72 put } for \ + { newpath 0 0 moveto s false charpath } loop", + ] { + runs_out_of_budget(body); + } +} + +#[test] +fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() { + // `pathbbox` reads each element of the current path, and `gsave`, `save` and `clip` copy it: + // each element counts against the operation budget. The path has 100,001 elements. + let path = "newpath 0 0 moveto 0 1 99999 { pop 1 0 rlineto } for"; + for body in ["{ pathbbox pop pop pop pop } loop", "{ gsave grestore } loop", "{ save restore } loop", "{ gsave clip grestore } loop"] { + runs_out_of_budget(&format!("{path} {body}")); + } +} + +#[test] +fn nested_loops_run_out_of_the_budget() { + // Each pass of `loop`, `repeat`, `for` and `forall` counts against the operation budget, and + // so does each item of a procedure that is not a name. `clear` empties the operand stack that + // the passes fill. + let literals = format!("{{ {} clear }} loop", "0 ".repeat(100_000)); + for body in [ + "{ {} loop } loop", + "{ 1000000 {} repeat } loop", + "{ 0 1 999999 {} for clear } loop", + "/s 1000000 string def { s {} forall clear } loop", + "/a 1000000 array def { a {} forall clear } loop", + "/d 1 dict def 0 1 99999 { d exch 0 put } for { d {} forall clear } loop", + literals.as_str(), + ] { + runs_out_of_budget(body); + } +} + +#[test] +fn paths_stop_at_the_path_limit() { + // A path holds at most 1,048,576 elements. An operator that would add more stops the program: + // the square drawn before it stays, and the square after it is left out. + for body in [ + "newpath 0 0 moveto 1100 { 1000 { 1 0 rlineto } repeat } repeat", + "newpath 0 0 moveto 0 0 0 0 0 0 1100 { 1000 { 6 copy rcurveto } repeat } repeat", + "newpath 0 0 moveto 1100 { 1000 { closepath } repeat } repeat", + // 1,024 turns at a radius of 2 × 10^17: about 1,171,000 curves. + "newpath 0 0 200000000000000000 0 368640 arc", + // At a radius of 10^300, kurbo divides the arc into more curves than a usize can count. + "newpath 0 0 10 300 exp 0 90 arc", + // The outlines of 100,000 H's, 13 elements each. + "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ + 0 0 moveto s false charpath", + // 210,000 rectangles of five elements each. + "/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill", + ] { + let doc = eps_soon(&format!("0 0 10 10 rectfill {body} 20 20 10 10 rectfill")); + assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["PostScript (path limit)"], "{body}"); + } + // A `moveto` replaces a `moveto` just before it (PostScript Language Reference §8.2, `moveto`), + // and so does the current point that `show` sets, so 550,000 pieces of text placed with + // `moveto` and `show` leave a path of one element. + let doc = eps_soon("/Helvetica findfont 10 scalefont setfont 550 { 1000 { 0 0 moveto ( ) show } repeat } repeat 0 0 10 10 rectfill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn pdf_paths_stop_at_the_path_limit() { + // A content stream's current path holds at most 1,048,576 elements: `re` (five elements each) + // and `h` stop the stream at the limit, and the square painted before it stays. + for (op, n) in [("0 0 1 1 re ", 210_000), ("h ", 1_100_000)] { + let doc = parse(&page_pdf(&format!("0 0 10 10 re f 0 0 m {} f 20 20 10 10 re f", op.repeat(n)), "", vec![])).unwrap(); + assert_eq!(doc.flatten().len(), 1, "{op}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["content (path limit)"], "{op}"); + } +} + #[test] fn closing_an_empty_path_is_a_no_op() { // "s", "b" and "b*" closed the path without a current point (kurbo asserts on that). From 642eed0802d6516a691c4e589a6983d91adbc665 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 14 Oct 2026 14:39:26 -0600 Subject: [PATCH 30/91] Properties/Timeline/Effect Controls: a typed component edits only that component of the other selected layers (#1434) With several layers selected, typing a value into one dimension (say Z Position) sent the edited layer's whole value with selected: true, and prop.set gave every selected layer that same value: they all took the first layer's X and Y and stacked on top of it. Scrubbing was fine (it sends an offset). prop.set takes dims: with selected and a typed value, the other selected layers get only those components and keep the rest of their own value (Position with Separate Dimensions included). The three panels send the components the edit changed (both for linked Scale). Without dims, agents still set the whole value on every selected layer. Fixes #1434 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/engine/src/commands/prop.rs | 24 ++++++++++- crates/engine/src/tests_timeline.rs | 42 ++++++++++++++++++ crates/ui-egui/src/panels/effect_controls.rs | 5 +++ crates/ui-egui/src/panels/properties.rs | 5 +++ crates/ui-egui/src/panels/timeline.rs | 5 +++ crates/ui-egui/tests/ui_inline_edit.rs | 45 ++++++++++++++++++++ 6 files changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 29da9c1e0..8f2638182 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -241,7 +241,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 { } /// `prop.set`; with `selected`, also on the other selected layers: the same value, or with -/// `offset` (a scrub) the same change from each layer's own value. With `offset` and +/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims` +/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers, +/// which keep the rest of their own value (#1434). With `offset` and /// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change /// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`, /// one that a continuing scrub keeps folding into). @@ -261,6 +263,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { .then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect()); let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len()))); let scalar = p.get("value").is_some_and(Value::is_number); + // Typed into some components only: the others stay each layer's own. + let dims: Option> = match (&delta, p.get("dims").and_then(Value::as_array), &new) { + (None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()), + _ => None, + }; + let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| { + if let (Some(dims), Some(n)) = (&dims, &new) + && let Some(mut c) = current_components(s, cid, olid, ouid) + { + for &d in dims { + if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) { + *slot = *v; + } + } + q["value"] = json!(c); + } + }; // `p` aimed at (layer, prop) with its value moved by `k` × the change. let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| { if let Some(d) = &delta @@ -291,6 +310,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { } let mut q = twin_params(p, olid, ouid); shifted(s, &mut q, olid, ouid, k); + typed(s, &mut q, olid, ouid); // (a layer that can't take it, e.g. a clamped or static property, keeps its value) let _ = set(s, &q); } @@ -1229,7 +1249,7 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}", has_layers, set_selected ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 71a9943c0..a7aa8bace 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -1193,3 +1193,45 @@ fn relative_trim_moves_every_selected_layer_by_the_same_delta() { s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap(); assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a)); } + +/// #1434: with several 3D layers selected, typing a value into one component (Z Position) sets +/// that component on each of them; their other components stay their own. Before, every +/// selected layer took the edited layer's whole value and they piled up on top of it. +#[test] +fn a_typed_component_applies_only_that_component_to_the_selected_layers() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + for (l, xy) in [(a, [10.0, 20.0]), (b, [110.0, 120.0]), (c, [210.0, 220.0])] { + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 5.0]})).unwrap(); + } + s.execute("layer.select", json!({"layers": [a, b, c]})).unwrap(); + let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value.components(); + let uid = prop(&s, a, "transform/position").uid; + // The panels send the edited layer's value with the typed component, and which one it was. + let undo = s.history.undo.len(); + s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, -300.0], "selected": true, "offset": false, "dims": [2]})).unwrap(); + assert_eq!(pos(&s, a), vec![10.0, 20.0, -300.0]); + assert_eq!(pos(&s, b), vec![110.0, 120.0, -300.0], "B keeps its X and Y"); + assert_eq!(pos(&s, c), vec![210.0, 220.0, -300.0], "C keeps its X and Y"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pos(&s, b), vec![110.0, 120.0, 5.0]); + + // Linked Scale: both edited components go to every layer, Z stays each layer's own. + s.execute("prop.set", json!({"layer": c, "path": "transform/scale", "value": [50.0, 50.0, 30.0]})).unwrap(); + let scale_uid = prop(&s, a, "transform/scale").uid; + s.execute("prop.set", json!({"layer": a, "prop": scale_uid, "value": [200.0, 200.0, 100.0], "selected": true, "dims": [0, 1]})).unwrap(); + assert_eq!(prop(&s, c, "transform/scale").value.components(), vec![200.0, 200.0, 30.0]); + + // Position with Separate Dimensions on one of the other layers. + s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, 40.0], "selected": true, "dims": [2]})).unwrap(); + let axis = |m: &str| prop(&s, b, &format!("transform/{m}")).value.as_f64(); + assert_eq!((axis("positionX"), axis("positionY"), axis("positionZ")), (110.0, 120.0, 40.0)); + + // Without `dims` (agents setting a whole value) every selected layer takes the value as is. + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap(); + assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]); +} diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..ecfed5a23 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -377,6 +377,11 @@ fn prop_row( let mut nc = c.clone(); nc[d] = nv; set(actions, json!(nc)); + // Typed into this component only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!([d]); + } } x = vr.max.x + 8.0; } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..5cb365137 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -306,6 +306,11 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } } set(actions, json!(nc)); + // Typed into these components only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!((from..from + shown).collect::>()); + } } x = vr.max.x + 10.0; } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..f54b1cd40 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4064,6 +4064,11 @@ fn value_editor( } } set(actions, json!(nc)); + // Typed into these components only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!((from..from + shown).collect::>()); + } } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; if d + 1 < n { diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b0..d7e532d76 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -539,3 +539,48 @@ fn arrow_keys_move_the_caret_in_rename_fields() { let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); assert_eq!(layer_name, "Pla-te"); } + +/// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X +/// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so +/// they stacked on top of it and seemed to disappear. +#[test] +fn typing_one_component_with_several_layers_selected_keeps_their_other_components() { + let (mut h, _) = harness(); + let (a, b, transform) = { + let s = &mut h.state_mut().session; + let mut ids = vec![]; + for (name, xy) in [("A", [40.0, 50.0]), ("B", [200.0, 120.0])] { + let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 0.0]})).unwrap(); + ids.push(l); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(ids[0])).unwrap().props.sub("transform").unwrap().uid; + (ids[0], ids[1], tr) + }; + h.state_mut().ui.timeline.open_layers.insert(a); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| { + h.state() + .session + .active_comp() + .unwrap() + .layer(effectcraft_engine::project::LayerId(l)) + .unwrap() + .props + .prop("transform/position") + .unwrap() + .value + .components() + }; + let position = + h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(a)).unwrap().props.prop("transform/position").unwrap().uid; + let p = center(&h, &format!("timeline.prop.{position}.value.2")); + click(&mut h, p, 1); + type_text(&mut h, "-300"); + key(&mut h, Key::Enter); + assert_eq!(pos(&h, a), [40.0, 50.0, -300.0]); + assert_eq!(pos(&h, b), [200.0, 120.0, -300.0], "B keeps its own X and Y"); +} From e0f25c77bb56d763d3b5f1312f76d0120f06769b Mon Sep 17 00:00:00 2001 From: caldiag Date: Wed, 14 Oct 2026 18:27:25 -0300 Subject: [PATCH 31/91] when double-clicking an effect property, it reveals that effect in the timeline --- crates/ui-egui/src/panels/effect_controls.rs | 5 ++- crates/ui-egui/src/panels/timeline.rs | 41 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..737988802 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -272,7 +272,10 @@ fn prop_row( let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y)); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text); - ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); + let name_response = ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); + if name_response.double_clicked() && super::timeline::reveal_effect_property(app, layer.id.0, uid) { + app.raise_panel(crate::dock::PanelKind::Timeline); + } app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); let merge = format!("ec-{uid}"); // With several layers selected the edit applies to the same effect property of each (#491). diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..c7114a28e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1134,6 +1134,21 @@ pub(crate) fn open_effect_paths(app: &mut EffectcraftApp, layer: u64, props: &[u app.ui.timeline.open_groups.extend(open); } +/// Show an Effect Controls property in its layer's Timeline Effects view. +pub(crate) fn reveal_effect_property(app: &mut EffectcraftApp, layer: u64, prop: u64) -> bool { + let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) else { return false }; + let Some(fx) = l.effects().map(|g| g.uid) else { return false }; + let path = enclosing_groups(l, prop); + if path.first() != Some(&fx) { + return false; + } + let tl = &mut app.ui.timeline; + tl.open_layers.insert(layer); + tl.layer_reveal.insert(layer, vec!["effects".into()]); + tl.open_groups.extend(path); + true +} + /// After a reveal shortcut that picks properties by state (EE expressions): those inside effects /// on `layers` show under their effect, twirled open. pub(crate) fn open_revealed(app: &mut EffectcraftApp, layers: &[u64], kinds: &[String]) { @@ -4317,6 +4332,32 @@ mod tests { assert!(labels(&app).iter().any(|r| r.trim() == "Scale")); } + #[test] + fn reveal_effect_property_opens_layer_effect_path_in_timeline() { + let mut app = app(); + let comp = app.session.active_comp().unwrap().clone(); + let bx = comp.layers.iter().find(|l| l.name == "Box").unwrap().id.0; + let prop = comp + .layer(LayerId(bx)) + .and_then(|layer| layer.effects()) + .and_then(|effects| effects.groups().next()) + .and_then(|effect| effect.get("blurriness")) + .unwrap() + .uid; + + assert!(reveal_effect_property(&mut app, bx, prop)); + assert!(app.ui.timeline.open_layers.contains(&bx)); + assert_eq!(app.ui.timeline.layer_reveal.get(&bx), Some(&vec!["effects".to_string()])); + + let rows = labels(&app); + let effect = rows.iter().position(|row| row.trim() == ">Gaussian Blur").unwrap(); + assert_eq!(rows.get(effect + 1).map(String::as_str).map(str::trim), Some("Blurriness"), "{rows:?}"); + assert!(rows.iter().any(|row| row.trim() == "Blur Dimensions"), "Effects view shows the effect's other properties too: {rows:?}"); + + let transform_prop = comp.layer(LayerId(bx)).and_then(|layer| layer.transform()).and_then(|group| group.get("position")).unwrap().uid; + assert!(!reveal_effect_property(&mut app, bx, transform_prop), "non-effect properties do not change the Effects view"); + } + #[test] fn columns_toggle_and_layout() { let mut app = app(); From 7a9c2a8c401271aa3917318400fda80fe5416b00 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:44:19 -0700 Subject: [PATCH 32/91] Fonts: honor Fontconfig configuration search paths Assisted-by: GPT-6 Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- crates/text/src/font_dirs.rs | 147 ++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 4 deletions(-) diff --git a/crates/text/src/font_dirs.rs b/crates/text/src/font_dirs.rs index 39714cad7..8004a62c0 100644 --- a/crates/text/src/font_dirs.rs +++ b/crates/text/src/font_dirs.rs @@ -19,6 +19,8 @@ pub(crate) struct FontEnv { pub xdg_config_home: Option, /// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones. pub fontconfig_file: Option, + /// `FONTCONFIG_PATH`: ordered search folders, before the standard configuration folders. + pub fontconfig_path: Option, /// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists). pub fontconfig_roots: Vec, /// Folders scanned if they exist (the usual system folders and the Flatpak host folders). @@ -34,6 +36,7 @@ impl FontEnv { xdg_data_dirs: var("XDG_DATA_DIRS"), xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from), fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from), + fontconfig_path: var("FONTCONFIG_PATH"), fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(), fixed: [ "/usr/share/fonts", @@ -87,10 +90,7 @@ const MAX_FILE_BYTES: u64 = 1 << 20; /// The font folders named by the fontconfig configuration. pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() }; - let root = match &env.fontconfig_file { - Some(f) => Some(f.clone()), - None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(), - }; + let root = fontconfig_root(env); if let Some(root) = root { st.file(&root, 0); } @@ -102,6 +102,40 @@ pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { st.out } +/// Select the main file as fontconfig does: absolute names bypass the search path; +/// relative names (or `fonts.conf`) use FONTCONFIG_PATH, then the standard folders. +fn fontconfig_root(env: &FontEnv) -> Option { + if let Some(file) = &env.fontconfig_file + && file.is_absolute() + { + return Some(file.clone()); + } + if env.fontconfig_file.is_none() && env.fontconfig_path.is_none() { + return env.fontconfig_roots.iter().find(|p| readable_config(p)).cloned(); + } + let file = env.fontconfig_file.as_deref().unwrap_or_else(|| Path::new("fonts.conf")); + let mut dirs = Vec::new(); + if let Some(path) = &env.fontconfig_path { + let mut paths = std::env::split_paths(path).peekable(); + while let Some(mut dir) = paths.next() { + // Fontconfig ignores a final empty item; an interior empty item names `/`. + if dir.as_os_str().is_empty() { + if paths.peek().is_none() { + continue; + } + dir = PathBuf::from("/"); + } + dirs.push(dir); + } + } + dirs.extend(env.fontconfig_roots.iter().filter_map(|p| p.parent().map(Path::to_path_buf))); + dirs.into_iter().map(|dir| dir.join(file)).find(|p| readable_config(p)) +} + +fn readable_config(path: &Path) -> bool { + path.is_file() && std::fs::File::open(path).is_ok() +} + struct Reader<'a> { env: &'a FontEnv, out: Vec, @@ -371,4 +405,109 @@ mod tests { assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::>()); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn fontconfig_path_selects_the_first_existing_config_in_search_order() { + let root = temp("config-search"); + let first = root.join("first"); + let second = root.join("second"); + write(&first.join("fonts.conf"), "/first/fonts"); + write(&second.join("fonts.conf"), "/second/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(std::env::join_paths([root.join("missing"), first.clone(), second.clone()]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/first/fonts")]); + env.fontconfig_path = Some(std::env::join_paths([second, first]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/second/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_relative_selected_config_uses_the_search_path_and_keeps_relative_includes() { + let root = temp("selected-config"); + let config = root.join("config"); + write(&config.join("chosen.conf"), "conf.d"); + write(&config.join("conf.d/10-fonts.conf"), "../../fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(config.clone().into_os_string()); + env.fontconfig_file = Some(PathBuf::from("chosen.conf")); + assert_eq!(fontconfig_dirs(&env), [config.join("conf.d/../../fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn an_absolute_selected_config_bypasses_the_search_path() { + let root = temp("absolute-config"); + write(&root.join("search/fonts.conf"), "/wrong/fonts"); + write(&root.join("selected.conf"), "/selected/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(root.join("search").into_os_string()); + env.fontconfig_file = Some(root.join("selected.conf")); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + env.fontconfig_file = Some(root.join("missing.conf")); + assert!(fontconfig_dirs(&env).is_empty(), "an explicit missing file does not pick another config"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn configuration_search_falls_back_to_standard_folders() { + let root = temp("default-config"); + let config = root.join("etc/fonts"); + write(&config.join("fonts.conf"), "/default/fonts"); + write(&config.join("selected.conf"), "/selected/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(root.join("missing").into_os_string()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/default/fonts")]); + env.fontconfig_file = Some(PathBuf::from("selected.conf")); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + env.fontconfig_path = None; + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn configuration_search_preserves_non_unicode_folder_names() { + use std::os::unix::ffi::OsStrExt; + let root = temp("non-unicode-config"); + let config = root.join(std::ffi::OsStr::from_bytes(b"config-\xff")); + write(&config.join("fonts.conf"), "/configured/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(config.into_os_string()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/configured/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn configuration_search_skips_unreadable_files() { + use std::os::unix::fs::PermissionsExt; + struct Cleanup { + root: PathBuf, + file: PathBuf, + permissions: std::fs::Permissions, + } + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::set_permissions(&self.file, self.permissions.clone()); + let _ = std::fs::remove_dir_all(&self.root); + } + } + let root = temp("unreadable-config"); + let first = root.join("first/fonts.conf"); + let second = root.join("second/fonts.conf"); + write(&first, "/unreadable/fonts"); + write(&second, "/readable/fonts"); + let _cleanup = Cleanup { root: root.clone(), file: first.clone(), permissions: std::fs::metadata(&first).unwrap().permissions() }; + std::fs::set_permissions(&first, std::fs::Permissions::from_mode(0)).unwrap(); + if std::fs::File::open(&first).is_ok() { + eprintln!("skipped: this process can read mode-000 files"); + return; + } + let mut env = env_in(&root); + env.fontconfig_roots = vec![first.clone(), second.clone()]; + env.fontconfig_path = Some(std::env::join_paths([first.parent().unwrap(), second.parent().unwrap()]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]); + env.fontconfig_path = None; + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]); + } } From a3c3b8530c927b198749e5468c669bc873d11f05 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 15:30:59 +0200 Subject: [PATCH 33/91] Text: a variable font's named instances are styles of its family (#1563) The font scan read only whether a face has an fvar table, so a variable family offered one style (its default instance), fontStyle = "Black" picked that face with synthetic bold, and an instance's PostScript name (Figtree-Black, what After Effects scripts use) found no face at all. The scan now reads the named instances (style, axis values, PostScript name, or the one Adobe Technical Note #5902 derives). They are styles of the family in text.fonts and the font menus, a style, PostScript name or full name selects the instance, and layout uses its axis values as the base variations; axes set on the text still win. --- crates/engine/src/tests_text_edit.rs | 60 +++++++ crates/text/src/fonts.rs | 139 ++++++++++++++-- crates/text/src/layout.rs | 44 ++++++ crates/text/src/sfnt.rs | 193 ++++++++++++++++++++++- crates/text/src/variable.rs | 10 +- crates/ui-egui/src/panels/text_panels.rs | 7 +- 6 files changed, 435 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 3e41688d2..243a9c676 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -317,6 +317,66 @@ fn fonts_filter_variable_families() { assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable)); } +/// A variable font of `name` and `fvar` only: one `wght` axis 100..900 (default 300, style +/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name +/// `-