From 605b7c3a25f265778dd27f0be9c3ff7a558f86e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:50:57 +0000 Subject: [PATCH 01/28] Scripting: scripts can't grant themselves file and network access (#590) A running script reached `prefs.set` through `app.run` and could turn on Settings > Scripting & Expressions > Allow Scripts to Write Files and Access Network (or turn off Warn User When Executing Files), which the File, Socket and system.callSystem gates then read: the permission the user withheld was the script's to take. Commands a script issues now go through `Session::execute_for_script`, which marks the session as running for a script for the command and everything it runs (engine.batch, ScriptUI callbacks, scheduled tasks). While it is, `prefs.set` refuses any change that would leave the Application Scripting settings different (compared on the resulting settings, so dotted keys, whole-page objects and batches are all covered), and a script's `prefs.reset` keeps them as the user set them. Command scripts read from a file (File > Scripts > Run Script File... with .json/.jsonl) count as scripts too. The UI, CLI, control channel and MCP still change them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/file_more.rs | 5 ++- crates/engine/src/commands/settings.rs | 12 ++++++++ crates/engine/src/lib.rs | 25 +++++++++++++++ crates/engine/src/prefs.rs | 19 ++++++++++++ crates/script/src/runtime.rs | 2 +- crates/script/src/tests.rs | 41 +++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index aa9e1be94..9aecd8d65 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -465,6 +465,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", "missing `path` (or inline `steps`)")), }; + // A command script read from a file is a script: it can't change what scripts may do. + let from_file = p.get("steps").is_none(); let mut results = vec![]; for (i, step) in steps.iter().enumerate() { let (id, params) = match (step.get("command").or(step.get("id")).and_then(Value::as_str), step.get("method").and_then(Value::as_str)) { @@ -478,7 +480,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result { if id == "file.runScript" || id == "script.run" { return Err(bad("file.runScript", "scripts can't run scripts")); } - let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?; + let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) }; + let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?; results.push(r); } Ok(json!({"steps": results.len(), "results": results})) diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs index 320a6c18a..1a5789e28 100644 --- a/crates/engine/src/commands/settings.rs +++ b/crates/engine/src/commands/settings.rs @@ -9,6 +9,10 @@ use crate::{EngineError, Result, Session, cmd, query}; // ---------------------------------------------------------------- prefs +/// Why a script can't change Application Scripting settings. +const SCRIPT_PERMISSIONS: &str = + "Scripts can't change what scripts are allowed to do. Change it yourself in Settings ▸ Scripting & Expressions ▸ Application Scripting."; + fn prefs_get(s: &mut Session, p: &Value) -> Result { let key = str_p(p, "key").unwrap_or(""); s.prefs.get(key).ok_or_else(|| bad("prefs.get", format!("unknown setting `{key}`"))) @@ -30,6 +34,9 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { for (k, v) in &pairs { next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?; } + if s.running_for_script() && !next.scripting.same_permissions(&s.prefs.scripting) { + return Err(EngineError::Other(SCRIPT_PERMISSIONS.into())); + } s.prefs = next; s.prefs_changed(); s.save_prefs(); @@ -39,7 +46,12 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { fn prefs_reset(s: &mut Session, p: &Value) -> Result { let page = str_p(p, "page"); + let before = s.prefs.scripting.clone(); s.prefs.reset(page).map_err(|e| bad("prefs.reset", e))?; + // A script's reset leaves what scripts may do as the user set it. + if s.running_for_script() { + s.prefs.scripting.keep_permissions(&before); + } s.prefs_changed(); s.save_prefs(); s.toast(match page { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9ee6b89f..d09cbf2b4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -472,8 +472,16 @@ pub struct Session { pub footage_stamps: std::collections::HashMap, /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). pub footage_scan: Option, + /// How many script-issued commands are running ([`Session::execute_for_script`]): while one + /// is, settings that guard what scripts may do can't change (see + /// [`prefs::Scripting::same_permissions`]). + pub script_calls: ScriptCalls, } +/// The count behind [`Session::running_for_script`]; only the engine changes it. +#[derive(Clone, Copy, Debug, Default)] +pub struct ScriptCalls(u32); + /// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8). pub type ThumbnailJob = Box Option<(u32, u32, Vec)> + Send>; @@ -559,6 +567,7 @@ impl Default for Session { check_footage_on_open: false, footage_stamps: Default::default(), footage_scan: None, + script_calls: ScriptCalls::default(), } } } @@ -601,6 +610,22 @@ impl Session { self.execute(id, params) } + /// [`Session::execute`] for a command a script asked for (the JavaScript engine's + /// `app.run`, ScriptUI callbacks…). The command and everything it runs count as the script's: + /// it may not change the settings that limit scripts (Settings ▸ Scripting & Expressions ▸ + /// Application Scripting), which only the user can, from the UI, CLI or control channel. + pub fn execute_for_script(&mut self, id: &str, params: Value) -> Result { + self.script_calls.0 = self.script_calls.0.saturating_add(1); + let r = self.execute(id, params); + self.script_calls.0 = self.script_calls.0.saturating_sub(1); + r + } + + /// Whether the command running was issued by a script ([`Session::execute_for_script`]). + pub fn running_for_script(&self) -> bool { + self.script_calls.0 > 0 + } + pub fn is_enabled(&self, id: &str) -> bool { commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 0bfa04a58..0ed7145e5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -309,6 +309,25 @@ page!(Scripting { error_banner: bool = true, }); +impl Scripting { + /// Whether `other` grants scripts the same as this: Application Scripting (file and network + /// access, the warning before executing files, the debugger). Scripts can't change these + /// ([`crate::Session::execute_for_script`]): a script could otherwise give itself the + /// access the user withheld. + pub fn same_permissions(&self, other: &Scripting) -> bool { + self.allow_scripts_write_files == other.allow_scripts_write_files + && self.warn_executing_files == other.warn_executing_files + && self.enable_js_debugger == other.enable_js_debugger + } + + /// Take `from`'s Application Scripting settings (see [`Scripting::same_permissions`]). + pub fn keep_permissions(&mut self, from: &Scripting) { + self.allow_scripts_write_files = from.allow_scripts_write_files; + self.warn_executing_files = from.warn_executing_files; + self.enable_js_debugger = from.enable_js_debugger; + } +} + /// One editable label: name and `#rrggbb` colour. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct LabelPref { diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 62479c142..712999ee0 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -152,7 +152,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { if CREATES_LAYER.contains(&id) { s.state.selected_layers.clear(); } - let r = s.execute(id, params); + let r = s.execute_for_script(id, params); if let Some(c) = target && prev != Some(c) && s.state.active_comp == Some(c) diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 946db49d5..b50a7bfb2 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -362,6 +362,47 @@ fn security_gate_for_files() { let _ = std::fs::remove_dir_all(&dir); } +/// #590: a script can't give itself file and network access (or turn off the warning before +/// executing files) by changing Settings ▸ Scripting & Expressions; the user still can. +#[test] +fn scripts_cant_change_what_scripts_may_do() { + let mut s = session(); + let attempts = [ + "app.run('prefs.set', {key: 'scripting.allowScriptsWriteFiles', value: true})", + "app.run('prefs.set', {values: {'scripting.warnExecutingFiles': false}})", + "app.run('prefs.set', {key: 'scripting', value: {allowScriptsWriteFiles: true}})", + "app.run('engine.batch', {steps: [{command: 'prefs.set', params: {key: 'scripting.enableJsDebugger', value: true}}]})", + ]; + for code in attempts { + let o = run_code(&mut s, code, "grant.jsx"); + let e = o.error.unwrap_or_else(|| panic!("`{code}` was allowed")); + assert!(e.message.contains("Scripts can't change what scripts are allowed to do"), "{code}: {e:?}"); + let sc = &s.prefs.scripting; + assert!(!sc.allow_scripts_write_files && sc.warn_executing_files && !sc.enable_js_debugger, "{code}"); + } + assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some()); + // Other settings, and the expression editor's, are still the script's to change. + ok(&mut s, "app.run('prefs.set', {values: {'general.undoLevels': 40, 'scripting.editorFontSize': 16}})"); + assert_eq!((s.prefs.general.undo_levels, s.prefs.scripting.editor_font_size), (40, 16)); + // The user grants access; a script's Reset Settings doesn't take it back or change it. + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); + ok(&mut s, "app.run('prefs.reset', {})"); + assert!(s.prefs.scripting.allow_scripts_write_files); + assert_eq!(s.prefs.general.undo_levels, effectcraft_engine::prefs::Prefs::default().general.undo_levels); + // A command script file is a script too. + let dir = std::env::temp_dir().join(format!("ec-script-grant-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("grant.json"); + std::fs::write(&f, r#"[{"command": "prefs.set", "params": {"key": "scripting.allowScriptsWriteFiles", "value": false}}]"#).unwrap(); + let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("Scripts can't change"), "{e}"); + assert!(s.prefs.scripting.allow_scripts_write_files); + // The user (UI, CLI, control channel) can take it back. + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": false})).unwrap(); + assert!(!s.prefs.scripting.allow_scripts_write_files); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn engine_entry_points() { let mut s = session(); From 34f2955585bd22f05fdde35c9b6b1f20ebd9193c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:07 +0000 Subject: [PATCH 02/28] Media: an audio file that fails to decode no longer stays silent for good (#324) With the Conformed Audio folder on (the default), the conform thread wrote whatever `decode_audio` returned, and that returned silence when the source couldn't be opened or a read failed. The conformed file is found by the source's path, size and date, so one failed decode (an MP3 the decoder stumbled on once) kept the footage silent in previews and exports in every later session, while a renamed copy played. The conform path now decodes with `try_decode_audio`, which fails instead of returning silence; a failed conform removes its temporary file, writes nothing and isn't retried this session (reads go to the decoder). Playback reads still fall back to silence. CONFORM_VERSION goes to 3 so the silent files earlier builds wrote are ignored and conformed again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/media/src/pool.rs | 106 +++++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 27 deletions(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 5ce5730f9..db3e19a42 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -53,8 +53,10 @@ struct Entry { } /// Conformed audio files written by builds before this version are ignored (and written again): -/// version 1 files of sources longer than about 12:36 at 48 kHz were silent from there on (#172). -const CONFORM_VERSION: u32 = 2; +/// version 1 files of sources longer than about 12:36 at 48 kHz were silent from there on (#172); +/// version 2 files were written silent where the source failed to decode, and kept the footage +/// silent from then on (#324). +const CONFORM_VERSION: u32 = 3; /// Footage longer than this (24 hours) is read from its decoder rather than conformed: its /// duration comes from the file's header, and a damaged one claiming days of audio had the @@ -200,6 +202,10 @@ struct Inner { /// Conformed files being written right now. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] conforming: Mutex>, + /// Conformed files that failed to write (the source didn't decode) this session: their + /// audio is read from the decoder instead of trying again on every read. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + conform_failed: Mutex>, } /// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus @@ -266,6 +272,7 @@ impl MediaPool { opened: AtomicU64::new(0), conform: Mutex::default(), conforming: Mutex::default(), + conform_failed: Mutex::default(), }), } } @@ -397,18 +404,23 @@ impl MediaPool { /// thread (once). #[cfg(not(target_arch = "wasm32"))] fn conform(&self, footage: &Footage, file: std::path::PathBuf, frames: usize, rate: u32) { - if !lock(&self.inner.conforming).insert(file.clone()) { + if lock(&self.inner.conform_failed).contains(&file) || !lock(&self.inner.conforming).insert(file.clone()) { return; } let (pool, f) = (self.clone(), footage.clone()); let spawned = std::thread::Builder::new().name("ec-conform-audio".into()).spawn(move || { let tmp = file.with_extension("tmp"); - let ok = file.parent().is_some_and(|d| std::fs::create_dir_all(d).is_ok()) - && pool.write_conformed(&f, &tmp, frames, rate).is_ok() - && std::fs::rename(&tmp, &file).is_ok(); - if !ok { + let written = match file.parent().map(std::fs::create_dir_all) { + Some(Ok(())) => pool.write_conformed(&f, &tmp, frames, rate).and_then(|()| std::fs::rename(&tmp, &file)), + Some(Err(e)) => Err(e), + None => Err(std::io::Error::other("no folder")), + }; + // A failed decode leaves no file: one written silent would keep the footage silent + // in every later session (#324). + if let Err(e) = written { let _ = std::fs::remove_file(&tmp); - log::warn!("media: could not write conformed audio {}", file.display()); + log::warn!("media: could not write conformed audio {} of {}: {e}", file.display(), f.path); + lock(&pool.inner.conform_failed).insert(file.clone()); } lock(&pool.inner.conforming).remove(&file); }); @@ -419,6 +431,7 @@ impl MediaPool { /// Write `frames` sample frames of `footage`'s audio at `rate` to `path` (raw interleaved /// stereo `f32`) a chunk at a time: the whole track was held in memory before it was written. + /// Fails when any chunk doesn't decode. #[cfg(not(target_arch = "wasm32"))] fn write_conformed(&self, footage: &Footage, path: &std::path::Path, frames: usize, rate: u32) -> std::io::Result<()> { use std::io::Write; @@ -426,7 +439,7 @@ impl MediaPool { let mut at = 0usize; while at < frames { let n = (frames - at).min(1 << 16); - for v in self.decode_audio(footage, sample_time(at, rate), n, rate) { + for v in self.try_decode_audio(footage, sample_time(at, rate), n, rate).map_err(std::io::Error::other)? { out.write_all(&v.to_le_bytes())?; } at += n; @@ -470,19 +483,27 @@ impl MediaPool { /// applied the edit list's priming offset twice and ramped the start of every read: AAC /// footage not at the mix rate stuttered in previews and exports (#274). fn decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { + self.try_decode_audio(footage, start, frames, rate).unwrap_or_else(|e| { + log::warn!("media: audio of {}: {e}", footage.path); + vec![0.0; frames * 2] + }) + } + + /// [`MediaPool::decode_audio`], failing when the source can't be opened or read. + fn try_decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> std::result::Result, String> { let path: Arc = footage.path.as_str().into(); - let Some(src) = self.inner.source(&path) else { return vec![0.0; frames * 2] }; + let src = self.inner.source(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); if native == 0 || rate == 0 || native == rate { - return read_stereo(&src, &path, s0, frames, rate); + return read_stereo(&src, s0, frames, rate); } // Output sample `n` sits at source position n × native / rate. let (native_i, rate_i) = (i128::from(native), i128::from(rate)); let first = (i128::from(s0) * native_i).div_euclid(rate_i); let last = ((i128::from(s0) + frames as i128) * native_i).div_euclid(rate_i) + 1; - let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return vec![0.0; frames * 2] }; - let buf = read_stereo(&src, &path, first64, count, native); + let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return Ok(vec![0.0; frames * 2]) }; + let buf = read_stereo(&src, first64, count, native)?; let mut out = Vec::with_capacity(frames * 2); for k in 0..frames as i128 { let pos = (i128::from(s0) + k) * native_i; @@ -494,7 +515,7 @@ impl MediaPool { out.push(a + (b - a) * f); } } - out + Ok(out) } /// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept. @@ -800,27 +821,21 @@ impl FootageSource for MediaPool { /// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which /// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both -/// channels; silence before the start and where decoding fails. -fn read_stereo(src: &SharedSource, path: &str, s0: i64, frames: usize, rate: u32) -> Vec { +/// channels; silence before the start. An error where decoding fails. +fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { let mut out = vec![0.0; frames * 2]; let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0); if skip >= frames { - return out; + return Ok(out); } - let buf = match src.audio(s0.max(0), frames - skip, rate) { - Ok(b) => b, - Err(e) => { - log::warn!("media: audio of {path}: {e}"); - return out; - } - }; - let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return out }; - let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return out }; + let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; + let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return Ok(out) }; + let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; for ([o0, o1], (a, b)) in dst.as_chunks_mut::<2>().0.iter_mut().zip(l.iter().zip(r)) { *o0 = *a; *o1 = *b; } - out + Ok(out) } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { @@ -880,6 +895,43 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #324: audio that fails to decode isn't conformed. A conformed file of silence was written + /// instead, and as it is found by the file's path, size and date the footage stayed silent + /// in every later session (a renamed copy played). + #[test] + fn audio_that_fails_to_decode_is_not_conformed() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-conform-fail"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("song.mp3"); + std::fs::write(&path, b"ID3, not really").unwrap(); + let footage = Footage { + path: path.to_string_lossy().into(), + kind: FootageKind::Audio, + duration: Tick::from_seconds_f64(2.0), + has_audio: true, + ..Default::default() + }; + let pool = MediaPool::new(); + let conformed = dir.join("conformed"); + pool.set_conform_folder(Some(conformed.clone())); + let file = pool.conformed_path(&footage.path, 48_000).unwrap(); + assert!(pool.audio_samples(&footage, Tick::ZERO, 1024, 48_000).iter().all(|v| *v == 0.0)); + for _ in 0..500 { + if lock(&pool.inner.conforming).is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(lock(&pool.inner.conforming).is_empty(), "the conform thread finished"); + assert!(!file.exists() && !file.with_extension("tmp").exists(), "no conformed file was left"); + assert!(lock(&pool.inner.conform_failed).contains(&file)); + // Later reads go to the decoder without trying again. + pool.audio_samples(&footage, Tick::ZERO, 1024, 48_000); + assert!(lock(&pool.inner.conforming).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn frame_index_loops_and_clamps() { let r = FrameRate::FPS_30; From da06c42de117efee111b25d48a7bb783019b5eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:12 +0000 Subject: [PATCH 03/28] Media: OpenEXR files with HTJ2K compression say it isn't supported (#482) The exr crate reads HTJ2K (OpenEXR 3.4's High-Throughput JPEG 2000) headers but can't decompress the pixels, and the image paths turned that into a misleading "no non-deep rgb channels" (multi-layer files) or imported the file and showed a transparent frame (single-layer files). `exr_channels::unsupported_compression` reads the headers; import (probe) and frame decoding fail with ": OpenEXR HTJ2K compression isn't supported yet: re-save the file with ZIP, PIZ or DWAA compression". Decoding HTJ2K itself needs a pure-Rust JPEG 2000 Part 15 decoder and is left for later. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/media/src/exr_channels.rs | 12 ++++++++++++ crates/media/src/pool.rs | 33 ++++++++++++++++++++++++++++++++ crates/media/src/probe.rs | 5 +++++ 3 files changed, 50 insertions(+) diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 06bda9259..732cd3c40 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -37,6 +37,18 @@ pub fn parse_manifest(json: &str) -> Vec<(String, u32)> { v } +/// Why an OpenEXR file's pixels can't be decoded when its headers name a compression this build +/// can't decompress (HTJ2K, OpenEXR 3.4's High-Throughput JPEG 2000): the decoders only said +/// "no non-deep rgb channels" or showed a transparent frame (#482). `None` for other files. +pub fn unsupported_compression(bytes: &[u8]) -> Option<&'static str> { + use exr::compression::Compression; + let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false).ok()?; + meta.headers + .iter() + .any(|h| matches!(h.compression, Compression::HTJ2K32 | Compression::HTJ2K256)) + .then_some("OpenEXR HTJ2K compression isn't supported yet: re-save the file with ZIP, PIZ or DWAA compression") +} + /// Read all channels of an EXR file's bytes. `None` when it is not a readable EXR. pub fn read_exr_channels(bytes: &[u8]) -> Option { use exr::prelude::*; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index db3e19a42..9db2f225c 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -731,6 +731,13 @@ impl Inner { if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { return Ok(img); } + // (an OpenEXR compression the decoder lacks came out transparent or as a missing + // channel error) + if path.to_ascii_lowercase().ends_with(".exr") + && let Some(why) = crate::exr_channels::unsupported_compression(&bytes) + { + return Err(MediaError::Decode(format!("{path}: {why}"))); + } let img = match image::load_from_memory(&bytes) { Ok(img) => img, // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. @@ -963,6 +970,32 @@ mod tests { assert!(c.take_spare(17).is_empty()); } + /// #482: an OpenEXR file with HTJ2K compression (which the decoder lacks) says so on import + /// and when its frame is read, instead of "no non-deep rgb channels" or a transparent frame. + #[test] + fn htj2k_exr_is_reported_as_unsupported() { + use exr::prelude::*; + let channels = + AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5; 4])), AnyChannel::new("G", FlatSamples::F32(vec![0.5; 4]))].into()); + let image = exr::image::Image::from_layer(Layer::new((2, 2), LayerAttributes::default(), Encoding::UNCOMPRESSED, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let mut bytes = bytes.into_inner(); + assert_eq!(crate::exr_channels::unsupported_compression(&bytes), None); + // The header's compression attribute (name, type, size 1, value) says HTJ2K32 (11). + let tag = b"compression\0compression\0\x01\0\0\0"; + let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len(); + bytes[at] = 11; + let b: Arc<[u8]> = bytes.into(); + let e = crate::probe_bytes("/shot.exr", b.clone()).unwrap_err().to_string(); + assert!(e.contains("/shot.exr: OpenEXR HTJ2K compression isn't supported yet"), "{e}"); + let pool = MediaPool::new(); + pool.add_bytes("/shot.exr", b); + let f = Footage { path: "/shot.exr".into(), kind: FootageKind::Still, width: 2, height: 2, has_video: true, ..Default::default() }; + let e = pool.frame_at(&f, Tick::ZERO).unwrap_err().to_string(); + assert!(e.contains("HTJ2K compression isn't supported yet"), "{e}"); + } + /// #411: Interpret Footage ▸ Preserve RGB reads a float OpenEXR's values as the file stores /// them (no automatic linear → sRGB encode), so an OCIO effect is the only conversion; the /// default interpretation still encodes. Both interpretations of one file decode separately. diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 4e986151c..6e8718fdf 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -158,6 +158,11 @@ pub(crate) fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Result { use image::ImageDecoder; + if fmt == image::ImageFormat::OpenExr + && let Some(why) = crate::exr_channels::unsupported_compression(bytes) + { + return Err(MediaError::Decode(format!("{path}: {why}"))); + } let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt); let (w, h, has_alpha) = match reader.into_decoder() { Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()), From ad7df67602c01c665ca12b0fd3b4910428ad3057 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 04/28] #360: the Project row menu's New Comp from Selection uses the whole selection Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/project.rs | 8 +++----- crates/ui-egui/tests/ui_project_rows.rs | 26 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 8f249b780..21f842976 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -599,12 +599,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.addItem".into(), json!({"item": id.0}))); ui.close(); } + // File ▸ New Comp from Selection on the selection (the right-clicked row joins it + // above): several items ask how in its dialog, as in After Effects (#360). if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() { - if let Some((w, h)) = it.dimensions() { - let d = it.duration().map(|d| d.seconds()).unwrap_or(10.0); - actions.push(("comp.new".into(), json!({"name": format!("{} Comp", it.name), "width": w, "height": h, "duration": d}))); - actions.push(("layer.addItem".into(), json!({"item": id.0}))); - } + actions.push(("file.newCompFromSelection".into(), json!({}))); ui.close(); } ui.separator(); diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 43daf2ed1..4c255cadd 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -148,6 +148,32 @@ fn footage_context_menu_interprets_the_footage() { assert_eq!(h.state().dialog, Some(Dialog::Form), "the Interpret Footage dialog opened"); } +/// #360: the row menu's New Comp from Selection works on the whole selection, as File ▸ New +/// Comp from Selection does: with several items selected it asks how in After Effects' New +/// Composition from Selection dialog, and Multiple Compositions makes one per item. +#[test] +fn context_menu_new_comp_from_selection_uses_the_selection() { + let (mut h, _, comp, solid) = harness(); + h.state_mut().session.state.project_selection = vec![comp, solid]; + h.run_steps(2); + let at = center(&h, &format!("project.item.{}.name", solid.0)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + assert_eq!(h.state().session.state.project_selection, vec![comp, solid], "the selection stays"); + let item = h.query_by_label_contains("New Comp from Selection").expect("in the menu").rect(); + click_at(&mut h, item.center()); + assert_eq!(h.state().dialog, Some(Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog"); + let comps = h.state().session.project.comps().count(); + h.state_mut().session.execute_checked("file.newCompFromSelection", json!({"single": false})).unwrap(); + assert_eq!(h.state().session.project.comps().count(), comps + 2, "one per item"); +} + /// Dragging from the Project panel's empty area draws a selection box that selects the rows it /// touches; a click there deselects (#203). #[test] From e4916e5ca937466e719d5f64dd4c70d6f261a8da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 05/28] #513: clicking off a mask's path, even on its layer, deselects its points Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/viewer.rs | 4 ++++ crates/ui-egui/tests/ui_viewer.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9adf007f8..e3cc44858 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1640,6 +1640,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => { if pick(app, &ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); + } else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() { + // A click away from the paths, even on their layer, deselects the mask + // points (the layer stays selected), as in After Effects (#513). + let _ = app.session.execute("mask.selectVertices", json!({"vertices": []})); } } } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 6b535b6cc..fd12d5553 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -750,6 +750,26 @@ fn pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask() { assert!(back.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, *c)), "{back:?}"); } +/// #513: clicking away from a mask's path, even on its own layer, deselects the mask's points; +/// the layer stays selected. +#[test] +fn clicking_off_a_mask_deselects_its_points() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + s.execute("mask.new", json!({"layer": plate, "vertices": [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]], "closed": true})).unwrap(); + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + let at = screen(&h, [500.0, 60.0]); + click(&mut h, at); + assert_eq!(h.state().session.state.selected_vertices.len(), 1, "a click on a point selects it"); + let away = screen(&h, [200.0, 250.0]); + click(&mut h, away); + assert!(h.state().session.state.selected_vertices.is_empty(), "a click off the path deselects its points"); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(plate)], "the layer stays selected"); +} + /// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging /// one pulls the path into a curve (#290). #[test] From bf5e6c6cec2e7a370cfa58b1deb50125f402755f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 06/28] #581: the Discord pill fits its label and field text sits on the field's middle Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/header.rs | 9 ++++++--- crates/ui-egui/src/widgets.rs | 26 ++++++++++++++++++++++++++ crates/ui-egui/tests/ui_panels.rs | 11 +++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1b93658f2..f92193f76 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -177,12 +177,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Right side: community buttons, workspaces. let mut rx = rect.max.x - 10.0; - let discord = Rect::from_min_max(pos2(rx - 104.0, cy - 13.0), pos2(rx, cy + 13.0)); + // The pill fits its label (icon, gap, text) with even padding: it was a fixed 104 px with the + // text at its left, which left a wide gap on the right (#581). + let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE); + let discord = Rect::from_min_max(pos2(rx - (10.0 + 14.0 + 6.0 + label.size().x + 12.0), cy - 13.0), pos2(rx, cy + 13.0)); let dresp = ui.interact(discord, egui::Id::new("hdr-discord"), Sense::click()); let dc = Color32::from_rgb(0x58, 0x65, 0xf2); p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc }); - icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 16.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); - p.text(pos2(discord.min.x + 28.0, cy), Align2::LEFT_CENTER, tr("Discord"), Tokens::semibold(12.0), Color32::WHITE); + icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); + p.galley(pos2(discord.min.x + 30.0, cy - label.size().y / 2.0), label, Color32::WHITE); app.auto.add("header.discord", discord, "Join the ArtCraft Discord"); if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() { let _ = app.session.execute("help.discord", json!({})); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52be..c37f8c34d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -277,6 +277,8 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea .hint_text(hint) .frame(egui::Frame::NONE) .desired_width(inner.width()) + // As tall as the field, so the text and the hint sit on its middle (they sat high, #581). + .min_size(inner.size()) .font(Tokens::ui(12.0)) .vertical_align(egui::Align::Center), ) @@ -559,3 +561,27 @@ pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { pub fn open_popup(ui: &Ui, id: egui::Id) { ui.data_mut(|d| d.insert_temp(id.with("open"), true)); } + +#[cfg(test)] +mod tests { + use super::*; + + /// #581: a field's text edit fills it, so its text and hint sit on the field's middle line + /// (it was a row at the top of the field, its text high). + #[test] + fn field_text_is_vertically_centred() { + let ctx = egui::Context::default(); + let t = Tokens::for_kind(Default::default()); + for h in [22.0, 26.0, 30.0] { + let field = Rect::from_min_size(pos2(20.0, 40.0), vec2(220.0, h)); + let mut text = String::new(); + let mut got = None; + let mut out = ctx.run_ui(Default::default(), |ui| { + got = Some(search_field(ui, field, &mut text, "Filter recent files", &t).rect); + }); + out.textures_delta.clear(); + let r = got.expect("the field was drawn"); + assert!((r.center().y - field.center().y).abs() < 0.5 && r.height() >= h - 2.5, "{h}: {r:?} in {field:?}"); + } + } +} diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146d..3b580921a 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -35,6 +35,17 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { h.run_steps(2); } +/// #581: the header's Discord pill fits its label with even padding (it was a fixed 104 px with +/// the text at its left). +#[test] +fn discord_pill_fits_its_label() { + let h = harness(); + let e = h.state().auto.find("header.discord").expect("the Discord pill").clone(); + let text = h.ctx.fonts_mut(|f| f.layout_no_wrap("Discord".into(), effectcraft_ui_egui::theme::Tokens::semibold(12.0), egui::Color32::WHITE).size().x); + // Padding 10, the 14 px icon, a 6 px gap, the text, padding 12. + assert!((e.rect[2] - (text + 42.0)).abs() < 0.5, "{} for a {text} px label", e.rect[2]); +} + #[test] fn window_menu_opens_the_new_panels() { let mut h = harness(); From dbbc0e2662f2a532c9a3e3dd277f2af9b669f5e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 07/28] #577: font previews are scaled and moved to fit inside their row Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/text_panels.rs | 72 ++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8e24a0835..df2e6dce2 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -580,10 +580,14 @@ fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui:: l } }; - let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); - for poly in lines.iter() { - let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); - ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + let room = Rect::from_min_max(pos2(resp.rect.max.x - 120.0, resp.rect.min.y + 1.0), pos2(resp.rect.max.x - 6.0, resp.rect.max.y - 1.0)); + if let Some((k, o)) = fit_preview(&lines, room, resp.rect.center().y + 5.0) { + let painter = ui.painter().with_clip_rect(resp.rect); + for poly in lines.iter() { + let pts: Vec = + poly.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| (o + vec2(p[0], p[1]) * k).to_pos2()).collect(); + painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + } } } if resp.clicked() { @@ -594,6 +598,30 @@ fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui:: }) } +/// Where a font's "Sample" outlines (`lines`, baseline at y = 0) go in `room`: `(scale, origin)`, +/// a point `p` landing at `origin + p * scale`. At their natural size on `baseline` when they fit; +/// fonts with large or odd metrics are scaled down (never up) and moved inside, so no preview +/// spills out of its row (#577). `None`: nothing to draw. +fn fit_preview(lines: &[Vec<[f32; 2]>], room: Rect, baseline: f32) -> Option<(f32, egui::Vec2)> { + let b = lines + .iter() + .flatten() + .filter(|p| p[0].is_finite() && p[1].is_finite()) + .fold(Rect::NOTHING, |b, p| b.union(Rect::from_min_max(pos2(p[0], p[1]), pos2(p[0], p[1])))); + if !(b.min.x <= b.max.x && b.min.y <= b.max.y) || !room.is_positive() { + return None; + } + let k = (room.width() / b.width().max(1e-3)).min(room.height() / b.height().max(1e-3)).min(1.0); + let mut o = vec2(room.min.x - b.min.x * k, baseline); + let (top, bottom) = (o.y + b.min.y * k, o.y + b.max.y * k); + if top < room.min.y { + o.y += room.min.y - top; + } else if bottom > room.max.y { + o.y -= bottom - room.max.y; + } + Some((k, o)) +} + /// Align panel: Align Layers to Selection / Composition, the six align buttons and the six /// Distribute Layers buttons. Buttons run `layer.align {edge, to}` and `layer.distribute /// {mode}` on the selected layers. @@ -722,3 +750,39 @@ fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Token p.rect_filled(Rect::from_min_size(pos2(c.x + 3.0, off(9.0)), vec2(5.0, 9.0)), 1.0, t.text_dim); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// #577: every font's "Sample" preview lands inside its row's preview box: at its natural size + /// when it fits, else scaled down; nothing for no outlines. + #[test] + fn font_previews_fit_their_row() { + let room = Rect::from_min_max(pos2(180.0, 101.0), pos2(294.0, 119.0)); + let baseline = 115.0; + let inside = |lines: &[Vec<[f32; 2]>]| { + let (k, o) = fit_preview(lines, room, baseline).expect("a preview"); + for p in lines.iter().flatten() { + let q = o + vec2(p[0], p[1]) * k; + assert!(room.expand(0.01).contains(pos2(q.x, q.y)), "{p:?} → {q:?} outside {room:?}"); + } + k + }; + // A plain 14 px sample fits as it is, on the baseline. + let plain = vec![vec![[0.0, -10.0], [60.0, -10.0], [60.0, 3.0], [0.0, 3.0]]]; + assert_eq!(inside(&plain), 1.0); + assert_eq!(fit_preview(&plain, room, baseline).map(|(_, o)| o.y), Some(baseline)); + // Huge, wide, tall, offset and negative-bearing outlines are scaled into the box. + for lines in [ + vec![vec![[0.0, -80.0], [400.0, 30.0]]], + vec![vec![[-50.0, -5.0], [500.0, 2.0]]], + vec![vec![[10.0, -200.0], [12.0, 0.0]]], + vec![vec![[0.0, 20.0], [30.0, 40.0]]], + ] { + assert!(inside(&lines) <= 1.0); + } + assert!(fit_preview(&[], room, baseline).is_none()); + assert!(fit_preview(&[vec![[f32::NAN, 1.0]]], room, baseline).is_none()); + } +} From a207b19b12da776ec3930ef9d75853a7efbc1f9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 08/28] #336: Ctrl+\ / Cmd+\ maximizes or restores the application window Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/menus.rs | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7851779ff..3d6ed3f04 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -122,6 +122,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.home", "Home", [], None), uic!("markers.dialog", "Marker Settings...", [], None), uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")), + uic!("window.maximizeApp", "Maximize or Restore Application Window", [], Some("Cmd+\\")), uic!("window.dockPanel", "Dock Panel", [], None), uic!("window.floatPanel", "Undock Panel", [], None), uic!("window.closePanel", "Close Panel", [], None), @@ -416,6 +417,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V _ => {} } } + // A UI-only command (not in the engine's menus), so `frontend` gets it from here. + if id == "window.maximizeApp" { + return frontend(app, ctx, id, params); + } // Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}. if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) { let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`"))); @@ -909,6 +914,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu }; Value::Null } + // Ctrl+\ / Cmd+\, as in After Effects (#336): `{maximized}`, the window's new state. + "window.maximizeApp" => { + let maximized = !ctx.input(|i| i.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized)); + json!({"maximized": maximized}) + } "view.fullScreen" => { let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full)); @@ -1857,6 +1868,34 @@ mod tests { } } + /// #336: Ctrl+\ (Cmd+\ on macOS) maximizes the application window, and again restores + /// it, as in After Effects. + #[test] + fn ctrl_backslash_maximizes_or_restores_the_window() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let (m, k) = parse_shortcut("Cmd+\\").unwrap(); + assert!(m.command && k == egui::Key::Backslash, "{m:?} {k:?}"); + assert!(bindings(&app.session).iter().any(|b| (b.0, b.1) == (m, k) && b.2 == "window.maximizeApp")); + let ctx = egui::Context::default(); + let run = |app: &mut EffectcraftApp, maximized: bool| { + let input = egui::RawInput { + viewports: std::iter::once((egui::ViewportId::ROOT, egui::ViewportInfo { maximized: Some(maximized), ..Default::default() })).collect(), + ..Default::default() + }; + let mut r = Value::Null; + let mut out = ctx.run_ui(input, |ui| r = invoke(app, ui.ctx(), "window.maximizeApp", json!({})).unwrap()); + out.textures_delta.clear(); + let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default(); + (r, cmds) + }; + let (r, cmds) = run(&mut app, false); + assert_eq!(r, json!({"maximized": true})); + assert!(cmds.contains(&egui::ViewportCommand::Maximized(true)), "{cmds:?}"); + let (r, cmds) = run(&mut app, true); + assert_eq!(r, json!({"maximized": false})); + assert!(cmds.contains(&egui::ViewportCommand::Maximized(false)), "{cmds:?}"); + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); From 29d9ba5fbbe464d343b5e29f10b3355a8f38da99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:28 +0000 Subject: [PATCH 09/28] i18n: the Home screen's buttons, pages, tiles, columns and empty states go through the interface catalog (#574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit panels/home.rs was in the converted list, but most of its text was drawn straight from English literals (New Project, Open Project…, the page tabs, the quick-start tiles, the filter placeholder, the column headings, the empty states, the Opened/Kind cells and the version line), so a Chinese interface still showed them in English. They now go through tr()/tr_args(), with rows in both Chinese catalogs (Traditional's Join the ArtCraft Discord row was also still English). Automation labels stay English. The Opened column's plurals are separate rows so each language words them itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/i18n/ui.rs | 64 ++++++++++++++++++++++++++++++- crates/ui-egui/src/panels/home.rs | 64 +++++++++++++++++++------------ 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index fdcaa27e6..e7e4db63e 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -996,6 +996,37 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Use In-Window Menu Bar on macOS", "在 macOS 上使用窗口内菜单栏"), ("Wireframe", "线框"), ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1(默认)"), + // The Home screen (#574). + ("New Project", "新建项目"), + ("Open Project…", "打开项目…"), + ("Templates", "模板"), + ("EffectCraft home page", "EffectCraft 主页"), + ("EffectCraft on GitHub", "GitHub 上的 EffectCraft"), + ("Motion graphics and visual effects · version {}", "动态图形和视觉效果 · 版本 {}"), + ("New Composition", "新建合成"), + ("Open Demo Project", "打开示例项目"), + ("Import Footage…", "导入素材…"), + ("Filter recent files", "筛选最近文件"), + ("NAME", "名称"), + ("OPENED", "打开时间"), + ("SIZE", "大小"), + ("KIND", "类型"), + ("Your recent files will show here", "你最近使用的文件会显示在这里"), + ("Create a new project, or open the demo project to look around.", "新建一个项目,或打开示例项目先熟悉一下界面。"), + ("No recent files match the filter", "没有与筛选条件匹配的最近文件"), + ("Try another name or folder.", "请尝试其他名称或文件夹。"), + ("Just now", "刚刚"), + ("Yesterday", "昨天"), + ("{} minute ago", "{} 分钟前"), + ("{} minutes ago", "{} 分钟前"), + ("{} hour ago", "{} 小时前"), + ("{} hours ago", "{} 小时前"), + ("{} day ago", "{} 天前"), + ("{} days ago", "{} 天前"), + ("EffectCraft project", "EffectCraft 项目"), + ("After Effects project", "After Effects 项目"), + ("Lottie animation", "Lottie 动画"), + ("Project", "项目"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1469,7 +1500,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Import PDF / Illustrator File", "Import PDF / Illustrator File"), ("Import Photoshop File", "Import Photoshop File"), ("Interpret Unlabeled Alpha As", "Interpret Unlabeled Alpha As"), - ("Join the ArtCraft Discord", "Join the ArtCraft Discord"), + ("Join the ArtCraft Discord", "加入 ArtCraft Discord"), ("Mercury GPU Acceleration ({})", "Mercury GPU Acceleration ({})"), ("Mercury Software Only", "Mercury Software Only"), ("OCIO (built-in ACES config)", "OCIO (built-in ACES config)"), @@ -1482,6 +1513,37 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Use In-Window Menu Bar on macOS", "Use In-Window Menu Bar on macOS"), ("Wireframe", "Wireframe"), ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (default)"), + // The Home screen (#574). + ("New Project", "新增專案"), + ("Open Project…", "開啟專案…"), + ("Templates", "範本"), + ("EffectCraft home page", "EffectCraft 首頁"), + ("EffectCraft on GitHub", "GitHub 上的 EffectCraft"), + ("Motion graphics and visual effects · version {}", "動態圖形與視覺效果 · 版本 {}"), + ("New Composition", "新增合成"), + ("Open Demo Project", "開啟範例專案"), + ("Import Footage…", "匯入素材…"), + ("Filter recent files", "篩選最近的檔案"), + ("NAME", "名稱"), + ("OPENED", "開啟時間"), + ("SIZE", "大小"), + ("KIND", "類型"), + ("Your recent files will show here", "你最近使用的檔案會顯示在這裡"), + ("Create a new project, or open the demo project to look around.", "新增一個專案,或開啟範例專案先熟悉一下介面。"), + ("No recent files match the filter", "沒有符合篩選條件的最近檔案"), + ("Try another name or folder.", "請嘗試其他名稱或資料夾。"), + ("Just now", "剛剛"), + ("Yesterday", "昨天"), + ("{} minute ago", "{} 分鐘前"), + ("{} minutes ago", "{} 分鐘前"), + ("{} hour ago", "{} 小時前"), + ("{} hours ago", "{} 小時前"), + ("{} day ago", "{} 天前"), + ("{} days ago", "{} 天前"), + ("EffectCraft project", "EffectCraft 專案"), + ("After Effects project", "After Effects 專案"), + ("Lottie animation", "Lottie 動畫"), + ("Project", "專案"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 861d7d496..46bbb44fc 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -15,6 +15,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde::Serialize; use serde_json::json; +use crate::i18n::{tr, tr_args}; use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -87,13 +88,13 @@ pub fn format_utc(secs: i64) -> String { /// date after a week). pub fn format_ago(now: i64, then: i64) -> String { let d = now.saturating_sub(then); - let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }); + let plural = |n: i64, one: &str, many: &str| tr_args(if n == 1 { one } else { many }, &[&n]); match d { - i64::MIN..60 => "Just now".to_string(), - 60..3_600 => plural(d / 60, "minute"), - 3_600..86_400 => plural(d / 3_600, "hour"), - 86_400..172_800 => "Yesterday".to_string(), - 172_800..604_800 => plural(d / 86_400, "day"), + i64::MIN..60 => tr("Just now").to_string(), + 60..3_600 => plural(d / 60, "{} minute ago", "{} minutes ago"), + 3_600..86_400 => plural(d / 3_600, "{} hour ago", "{} hours ago"), + 86_400..172_800 => tr("Yesterday").to_string(), + 172_800..604_800 => plural(d / 86_400, "{} day ago", "{} days ago"), _ => format_utc(then).get(..10).unwrap_or_default().to_string(), } } @@ -112,14 +113,14 @@ pub fn format_size(bytes: u64) -> String { } } -/// The Kind column: what a recent file is, by its extension. +/// The Kind column: what a recent file is, by its extension, in the interface language. fn kind_of(path: &str) -> &'static str { - match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { + tr(match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { Some("ecproj") => "EffectCraft project", Some("aep" | "aepx") => "After Effects project", Some("json" | "lottie") => "Lottie animation", _ => "Project", - } + }) } /// The config-store name of a project's thumbnail. @@ -237,7 +238,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: let mut y = rail.min.y + 20.0; for (label, id, primary) in [("New Project", "file.newProject", true), ("Open Project…", "file.open", false)] { let r = Rect::from_min_size(pos2(x0, y), vec2(w, 34.0)); - if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() { + if widgets::text_button(ui, r, tr(label), primary, &t, egui::Id::new(("home", id))).clicked() { actions.push((id, json!({}))); } app.auto.add(&format!("home.{id}"), r, label); @@ -261,7 +262,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: } let col = if on { t.tab_text_active } else { t.text_dim }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, col); - p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(13.0), col); + p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(13.0), col); app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label); if resp.clicked() { app.ui.home_learn = k == 2; @@ -281,7 +282,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: let foot_h = 22.0 + links.len() as f32 * 30.0 + 22.0 + sib_rows * 26.0; let mut cy = (rail.max.y - 16.0 - foot_h).max(y + 16.0); p.line_segment([pos2(x0, cy - 10.0), pos2(x0 + w, cy - 10.0)], Stroke::new(1.0, t.separator)); - p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, crate::i18n::tr("Community"), Tokens::semibold(12.0), t.text_dim); + p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, tr("Community"), Tokens::semibold(12.0), t.text_dim); cy += 22.0; for (icon, label, cmd) in links { let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0)); @@ -294,14 +295,14 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: } let col = if discord { Color32::WHITE } else { t.text }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(13.0, 13.0)), icon, col); - p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), col); + p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.0), col); app.auto.add(&format!("home.{cmd}"), r, label); if resp.clicked() { let _ = app.session.execute(cmd, json!({})); } cy += 30.0; } - p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint); + p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint); cy += 20.0; let sw = (w - 6.0) / 2.0; for (i, (name, slug)) in sib.iter().enumerate() { @@ -328,12 +329,12 @@ fn home_page( let t = app.tokens; let (x0, w) = (area.min.x, area.width()); let mut y = area.min.y; - p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active); + p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active); y += 40.0; p.text( pos2(x0, y + 8.0), Align2::LEFT_CENTER, - format!("Motion graphics and visual effects · version {}", env!("CARGO_PKG_VERSION")), + tr_args("Motion graphics and visual effects · version {}", &[&env!("CARGO_PKG_VERSION")]), Tokens::ui(12.5), t.text_dim, ); @@ -355,7 +356,7 @@ fn home_page( let ir = Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(32.0, 32.0)); p.rect_filled(ir, 7.0, t.accent.gamma_multiply(0.18)); icons::paint(p, ir.shrink(8.0), icon, t.accent); - p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), t.text); + p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.5), t.text); app.auto.add(&format!("home.{id}"), r, label); if resp.clicked() { if id == "templates" { @@ -369,18 +370,18 @@ fn home_page( y += 64.0 + 36.0; // Recent: heading, filter, clear. - p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, crate::i18n::tr("Recent"), Tokens::semibold(17.0), t.tab_text_active); + p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, tr("Recent"), Tokens::semibold(17.0), t.tab_text_active); let entries = recent_entries(&app.session.prefs); let fid = egui::Id::new("home-filter"); let mut filter: String = ctx.data(|d| d.get_temp(fid)).unwrap_or_default(); let fr = Rect::from_min_size(pos2(x0 + w - 240.0, y), vec2(240.0, 26.0)); - widgets::search_field(ui, fr, &mut filter, "Filter recent files", &t); + widgets::search_field(ui, fr, &mut filter, tr("Filter recent files"), &t); app.auto.add("home.filter", fr, "Filter recent files"); ctx.data_mut(|d| d.insert_temp(fid, filter.clone())); if !entries.is_empty() { let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.0, 20.0)); let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click()); - p.text(cr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint }); + p.text(cr.right_center(), Align2::RIGHT_CENTER, tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint }); app.auto.add("home.clearRecent", cr, "Clear Recent Projects"); if resp.clicked() { actions.push(("file.clearRecent", json!({}))); @@ -390,7 +391,7 @@ fn home_page( // Column headings (Name / Opened / Size / Kind, as in After Effects). let (opened_x, size_x, kind_x) = (x0 + w * 0.52, x0 + w * 0.68, x0 + w * 0.80); for (label, x) in [("NAME", x0 + 76.0), ("OPENED", opened_x), ("SIZE", size_x), ("KIND", kind_x)] { - p.text(pos2(x, y), Align2::LEFT_CENTER, label, Tokens::medium(10.5), t.text_faint); + p.text(pos2(x, y), Align2::LEFT_CENTER, tr(label), Tokens::medium(10.5), t.text_faint); } y += 12.0; p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator)); @@ -406,8 +407,8 @@ fn home_page( } else { ("No recent files match the filter", "Try another name or folder.") }; - p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, head, Tokens::semibold(14.0), t.text); - p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, sub, Tokens::ui(12.0), t.text_faint); + p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, tr(head), Tokens::semibold(14.0), t.text); + p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, tr(sub), Tokens::ui(12.0), t.text_faint); } let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); let row_h = 48.0; @@ -439,7 +440,7 @@ fn home_page( Tokens::medium(13.0), if e.exists { t.tab_text_active } else { t.text_dim }, ); - let sub = if e.exists { e.folder.clone() } else { format!("{} (missing)", e.folder) }; + let sub = if e.exists { e.folder.clone() } else { format!("{} {}", e.folder, crate::i18n::tr("(missing)")) }; name_clip.text(pos2(tr.max.x + 6.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint); let cell = |x: f32, x1: f32, text: String| { p.with_clip_rect(Rect::from_min_max(pos2(x, r.min.y), pos2(x1 - 8.0, r.max.y))).text( @@ -534,6 +535,21 @@ mod tests { assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project")); } + /// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind + /// columns read in Chinese, and English stays English. + #[test] + fn home_text_follows_the_interface_language() { + let now = 1_790_000_000; + crate::i18n::set_current("zh-hans"); + let zh = (format_ago(now, now - 5), format_ago(now, now - 3 * 3_600), format_ago(now, now - 90_000), kind_of("/a/B.ecproj")); + crate::i18n::set_current("zh-hant"); + let tw = (format_ago(now, now - 60), kind_of("x")); + crate::i18n::set_current("en"); + assert_eq!(zh, ("刚刚".into(), "3 小时前".into(), "昨天".into(), "EffectCraft 项目")); + assert_eq!(tw, ("1 分鐘前".into(), "專案")); + assert_eq!(tr("Filter recent files"), "Filter recent files"); + } + #[test] fn home_screen_registers_its_controls() { let mut s = effectcraft_engine::Session::default(); From 76c9abad501b5250029f85a2d97a4119b8064475 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:36 +0000 Subject: [PATCH 10/28] Viewer: the 3D Renderer menu shows the comp's renderer and switches it (#307) The Composition panel's bottom bar drew a hard-coded "Classic 3D" dropdown that ignored the comp's renderer and did nothing when clicked, so an Advanced 3D comp still read Classic 3D. It now shows the comp's renderer (translated) and, as in After Effects, its menu switches it through comp.settings (one undo step); the automation label is the renderer's name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/viewer_tools.rs | 18 +++++++++++-- crates/ui-egui/tests/ui_3d.rs | 31 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 06679783e..28714e184 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -719,9 +719,23 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, x += 4.0; // 3D renderer + view. if comp.has_3d() { + // The comp's 3D renderer, as After Effects' Composition panel shows it: the menu switches + // it through Composition Settings (undoable), and the automation label is the renderer. + use effectcraft_engine::project::Renderer; + let renderers = [(Renderer::Classic3D, "Classic 3D", "classic3D"), (Renderer::Advanced3D, "Advanced 3D", "advanced3D")]; + let current = renderers.iter().find(|(r, ..)| *r == comp.renderer).map_or("Classic 3D", |(_, label, _)| *label); let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0)); - let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d")); - app.auto.add("viewer.renderer3d", r, "3D Renderer"); + if widgets::dropdown(ui, r, crate::i18n::tr(current), &t, egui::Id::new("vw-3d")).clicked() { + toggle_popup(ui, "vw-3d-pop"); + } + app.auto.add("viewer.renderer3d", r, current); + let items: Vec<(String, bool)> = renderers.iter().map(|(r, label, _)| (label.to_string(), *r == comp.renderer)).collect(); + if let Some((renderer, _, id)) = popup(app, ui, "vw-3d-pop", r, &items, "renderer3dItem").and_then(|i| renderers.get(i)) + && *renderer != comp.renderer + && let Err(e) = app.session.execute("comp.settings", json!({"renderer": id})) + { + app.ui.status = e.to_string(); + } x = r.max.x + 4.0; let cid = app.session.active_comp_id(); let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index 385bf7acc..0c65084bf 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -47,6 +47,37 @@ fn viewer_registers_3d_controls() { assert!(ids.iter().any(|i| i.starts_with("viewer.camera.")), "camera wireframe registered"); } +/// Click the centre of the automation element `id`. +fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let [x, y, w, hh] = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is registered")).rect; + let p = egui::pos2(x + w / 2.0, y + hh / 2.0); + h.input_mut().events.push(egui::Event::PointerMoved(p)); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// #307: the viewer's 3D Renderer menu shows the comp's renderer (it always read "Classic 3D") +/// and switches it, as After Effects' Composition panel does. +#[test] +fn renderer_menu_shows_and_switches_the_comp_renderer() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + h.run_steps(3); + let label = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find("viewer.renderer3d").map(|e| e.label.clone()); + assert_eq!(label(&h).as_deref(), Some("Classic 3D")); + click_id(&mut h, "viewer.renderer3d"); + click_id(&mut h, "viewer.renderer3dItem.1"); + let renderer = h.state().session.active_comp().unwrap().renderer; + assert_eq!(renderer, effectcraft_engine::project::Renderer::Advanced3D); + h.run_steps(2); + assert_eq!(label(&h).as_deref(), Some("Advanced 3D")); + // One undoable Composition Settings step. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().renderer, effectcraft_engine::project::Renderer::Classic3D); +} + #[test] fn camera_and_light_dialogs_create_layers() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); From e152dfd1d187de780fd73705df5d71b7506308f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:47 +0000 Subject: [PATCH 11/28] Essential Graphics: Scale keeps its Constrain Proportions link, on by default (#338) A Scale property added to Essential Graphics fell into the generic Point control: two independent fields with no link, so dragging one value distorted the layer, unlike After Effects, whose Scale control keeps the chain link. It now draws the link (on by default) with one field per axis; linked, an edit scales the others in proportion (Alt edits one), using the Timeline's constrained() (moved to widgets). The link state is the property's own, shared with the Timeline's chain link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/essential.rs | 31 ++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 22 +------------ crates/ui-egui/src/widgets.rs | 20 ++++++++++++ crates/ui-egui/tests/ui_panels.rs | 44 ++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 26d458d4e..2496b2381 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -375,6 +375,37 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt }); r.response.rect } + // Scale keeps its Constrain Proportions link, on by default, as in After Effects (#338). + // The link is the property's own, shared with the Timeline's chain link. + ControlType::Point if p.match_id == "scale" && matches!(p.ui, ParamUi::Percent) => { + let comps = v.components(); + let linked = !app.ui.timeline.unlinked.contains(&p.uid); + let (lr, link) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); + let t = app.tokens; + crate::icons::paint(ui.painter(), lr, crate::icons::Icon::Link, if linked { t.accent } else { t.text_faint }); + let link = link.on_hover_text(tr("Constrain Proportions")); + app.auto.add(&format!("{auto}.link"), lr, "Constrain Proportions"); + if link.clicked() && !app.ui.timeline.unlinked.remove(&p.uid) { + app.ui.timeline.unlinked.insert(p.uid); + } + let mut rect = lr; + for (d, &old) in comps.iter().enumerate() { + let mut x = old; + let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix(if d + 1 == comps.len() { "%" } else { "" }).max_decimals(1)); + app.auto.add(&format!("{auto}.{d}"), r.rect, &p.name); + rect = rect.union(r.rect); + if r.changed() { + // Alt edits one value of a linked pair, as in the Timeline. + let next = if linked && !ui.input(|i| i.modifiers.alt) { + widgets::constrained(&comps, d, x) + } else { + comps.iter().enumerate().map(|(e, &c)| if e == d { x } else { c }).collect() + }; + out = Some(json!(next)); + } + } + rect + } ControlType::Point => { let mut a = v.as_vec2(); let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439be..151495604 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3440,26 +3440,6 @@ fn fmt_num(v: f64, d: usize) -> String { format!("{v:.d$}") } -/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio -/// (Constrain Proportions); from 0 the others that are 0 too follow the value. -fn constrained(c: &[f64], d: usize, v: f64) -> Vec { - let Some(&old) = c.get(d) else { return c.to_vec() }; - c.iter() - .enumerate() - .map(|(e, &x)| { - if e == d { - v - } else if old.abs() > 1e-9 { - x * v / old - } else if x.abs() <= 1e-9 { - v - } else { - x - } - }) - .collect() -} - /// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a /// multi-dimension value's fields, one per dimension (none for other values). fn value_editor( @@ -3535,7 +3515,7 @@ fn value_editor( // Alt edits one value of a linked pair. let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 }; let from = if shown == 1 { d } else { 0 }; - for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { if let Some(slot) = nc.get_mut(from + e) { *slot = v; } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52be..fdb8287b4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -559,3 +559,23 @@ pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { pub fn open_popup(ui: &Ui, id: egui::Id) { ui.data_mut(|d| d.insert_temp(id.with("open"), true)); } + +/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio +/// (Constrain Proportions); from 0 the others that are 0 too follow the value. +pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter() + .enumerate() + .map(|(e, &x)| { + if e == d { + v + } else if old.abs() > 1e-9 { + x * v / old + } else if x.abs() <= 1e-9 { + v + } else { + x + } + }) + .collect() +} diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146d..0ecefa48c 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -271,3 +271,47 @@ fn panels_snapshot() { img.save(format!("{dir}/{panel}{scope}.png")).unwrap(); } } + +/// Drag the automation element `id` horizontally by `dx` points. +fn drag_x(h: &mut Harness<'_, EffectcraftApp>, id: &str, dx: f32) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + let from: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(dx * k as f32 / 10.0, 0.0))); + h.step(); + } + let to = from + egui::vec2(dx, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +/// #338: an Essential Graphics Scale control keeps After Effects' Constrain Proportions link, on +/// by default: dragging X scales Y with it; unlinked, X moves alone. +#[test] +fn essential_graphics_scale_keeps_constrain_proportions() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/scale", "value": [100.0, 50.0]})).unwrap(); + let c = + h.state_mut().session.execute("essential.addProperty", json!({"layer": layer, "path": "transform/scale"})).unwrap()["controls"][0].as_u64().unwrap(); + open(&mut h, "essentialGraphics"); + let value = format!("essential.control.{c}.value"); + let scale = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layers.iter().find(|l| l.id.0 == layer).unwrap(); + l.props.prop("transform/scale").unwrap().value.components() + }; + assert!(h.state().auto.find(&format!("{value}.link")).is_some(), "the chain link is drawn"); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let linked = scale(&h); + assert!(linked[0] > 100.0, "{linked:?}"); + assert!((linked[1] / linked[0] - 0.5).abs() < 1e-6, "Y follows X in proportion: {linked:?}"); + click(&mut h, &format!("{value}.link")); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let unlinked = scale(&h); + assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); +} From 69bf806ff5acfae4028d30198057de76b7b40adf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:48 +0000 Subject: [PATCH 12/28] =?UTF-8?q?Graph=20Editor:=20buttons=20that=20don't?= =?UTF-8?q?=20fit=20the=20bar=20move=20to=20a=20=C2=BB=20menu=20instead=20?= =?UTF-8?q?of=20being=20cut=20off=20(#457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Graph Editor's bottom bar lays out 17 text buttons in one row (about 1100 px) on a painter clipped to the graph area, so on a normal-width Timeline the right-hand ones (keyframe interpolation and easing) were cut off and unreachable. Buttons that would pass the bar's right edge now go, in order, to a » menu at its end; picking one there runs the same action on the next frame. Hidden buttons register as timeline.graph.more. while the menu is open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/graph.rs | 121 ++++++++++++++++++++++----- crates/ui-egui/tests/ui_keyframes.rs | 55 ++++++++++++ 2 files changed, 155 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7f19d91b4..2dbb98268 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -14,8 +14,8 @@ use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; use super::timeline::TMap; -use crate::EffectcraftApp; use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; type Actions = Vec<(String, serde_json::Value)>; @@ -162,10 +162,88 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl out } -fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: &mut f32, y: f32, label: &str, on: bool, id: &str, tip: &str) -> bool { +/// The Graph Editor's button bar, laid out left to right. Buttons that would run past the right +/// edge (a Timeline narrower than the whole row, #457) go to the "»" menu at its end instead of +/// being clipped, so every one stays reachable. +struct ButtonBar { + x: f32, + /// Where the next button must end to stay on the bar (the "»" button's room is kept free). + limit: f32, + /// Buttons moved to the "»" menu: (automation id, label, on, tooltip). + hidden: Vec<(String, String, bool, String)>, + /// The id picked from the "»" menu last frame, run by its button this frame. + chosen: Option, +} + +/// The "»" menu's popup and the id it picked (read by the next frame's buttons). +const MORE_POPUP: &str = "graph-more"; + +impl ButtonBar { + const MORE_W: f32 = 24.0; + + fn new(ctx: &egui::Context, bar: Rect) -> Self { + let chosen = ctx.data_mut(|d| d.remove_temp::(egui::Id::new(MORE_POPUP).with("chosen"))); + ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen } + } + + /// The "»" button and its menu, when buttons did not fit. + fn more(&mut self, app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect) { + if self.hidden.is_empty() { + return; + } + let t = app.tokens; + let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0)); + let pid = egui::Id::new(MORE_POPUP); + let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More")); + p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) }); + p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text); + app.auto.add("timeline.graph.more", r, "More"); + if resp.clicked() { + let open = widgets::popup_is_open(ui, pid); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + if !widgets::popup_is_open(ui, pid) { + return; + } + let rows = self.hidden.len(); + let pos = r.left_top() - vec2(0.0, rows as f32 * 22.0 + 18.0); + let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { + let mut chosen = None; + for (id, label, on, tip) in &self.hidden { + let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); + if item.clicked() { + chosen = Some(id.clone()); + } + } + chosen + }); + if let Some(id) = chosen { + ui.data_mut(|d| d.insert_temp(pid.with("chosen"), id)); + ui.ctx().request_repaint(); + } + } +} + +fn text_button( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + bar: &mut ButtonBar, + y: f32, + label: &str, + on: bool, + id: &str, + tip: &str, +) -> bool { let t = app.tokens; let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; - let r = Rect::from_min_size(pos2(*x, y - 9.0), vec2(w, 18.0)); + // Once one button overflows, the rest follow it into the menu (they keep their order). + if !bar.hidden.is_empty() || bar.x + w > bar.limit { + bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); + return bar.chosen.as_deref() == Some(id); + } + let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); p.rect_filled( r, @@ -180,7 +258,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x ); p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); - *x += w + 4.0; + bar.x += w + 4.0; resp.clicked() } @@ -446,14 +524,14 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte p.rect_filled(bar, 0.0, t.panel_bg); p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); let y = bar.center().y; - let mut x = bar.min.x + 8.0; + let mut buttons = ButtonBar::new(&ctx, bar); let tl = &app.ui.timeline; let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom); if text_button( app, ui, p, - &mut x, + &mut buttons, y, "Auto", auto_type, @@ -463,45 +541,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into(); } // Choosing a graph type turns Auto-Select off, as in After Effects. - if text_button(app, ui, p, &mut x, y, "Value", !speed, "valueGraph", "Edit Value Graph") { + if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") { app.ui.timeline.graph_mode = "value".into(); } - if text_button(app, ui, p, &mut x, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { + if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { app.ui.timeline.graph_mode = "speed".into(); } - x += 6.0; - if text_button(app, ui, p, &mut x, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { + buttons.x += 6.0; + if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { app.ui.timeline.graph_show_selected = !show_sel; } - if text_button(app, ui, p, &mut x, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { + if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { app.ui.timeline.graph_auto_zoom = !auto_on; } - if text_button(app, ui, p, &mut x, y, "Fit", false, "fit", "Fit all graphs to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") { app.ui.timeline.graph_auto_zoom = true; app.ui.timeline.pps = None; } - if text_button(app, ui, p, &mut x, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { fit_selection(app, &cs, tm, plot); } - x += 6.0; + buttons.x += 6.0; let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box); - if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { + if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { app.ui.timeline.graph_snap = !snap; } - if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") { + if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") { app.ui.timeline.graph_reference = !reference; } - if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { + if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { app.ui.timeline.graph_transform_box = !tbox; } - x += 6.0; + buttons.x += 6.0; let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0)); - if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") + if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0)) { actions.push(("prop.separateDimensions".into(), json!({"layer": l}))); } - x += 6.0; + buttons.x += 6.0; for (label, cmd, params, tip) in [ ("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"), ("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"), @@ -511,10 +589,11 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"), ] { let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or(""); - if text_button(app, ui, p, &mut x, y, label, false, &id, tip) { + if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) { actions.push((cmd.into(), params)); } } + buttons.more(app, ui, p, bar); } /// Zoom time and value to the selected keys (and their handles). diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 50ccf1ef2..3ca85f8f5 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -293,3 +293,58 @@ fn a_graph_handle_drag_moves_the_handles_of_every_selected_key() { assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); assert!((handle(&h, 0).x - k0.x - px_per_s * 0.2).abs() < 2.0, "key 0's handle moved on screen"); } + +/// #457: on a Timeline narrower than the Graph Editor's whole button row, the buttons that don't +/// fit go to a "»" menu at the end of the bar instead of being cut off; each stays reachable and +/// works from there. +#[test] +fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() { + let (mut h, _, _) = harness(); + h.set_size(egui::vec2(1280.0, 800.0)); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + let ids = [ + "autoSelectGraphType", + "valueGraph", + "speedGraph", + "showSelected", + "autoZoom", + "fit", + "fitSelection", + "snap", + "reference", + "transformBox", + "separateDimensions", + "interpolationHold", + "interpolationLinear", + "interpolationAuto", + "easyEaseEasy", + "easyEaseInEase", + "easyEaseOutEase", + ]; + let graph = h.state().auto.find("timeline.graph").expect("the Graph Editor is shown").rect; + let right = graph[0] + graph[2]; + let shown: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.{id}")).is_some()).collect(); + for id in &shown { + let r = h.state().auto.find(&format!("timeline.graph.{id}")).unwrap().rect; + assert!(r[0] + r[2] <= right, "{id} is drawn past the bar's right edge"); + } + let more = h.state().auto.find("timeline.graph.more").expect("the buttons that don't fit are in a menu").rect; + assert!(more[0] + more[2] <= right); + let p = pos2(more[0] + more[2] / 2.0, more[1] + more[3] / 2.0); + let click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + }; + click(&mut h, p); + let hidden: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.more.{id}")).is_some()).collect(); + assert_eq!(shown.len() + hidden.len(), ids.len(), "every button is on the bar or in the menu: {shown:?} + {hidden:?}"); + assert!(hidden.contains(&"transformBox"), "{hidden:?}"); + let before = h.state().ui.timeline.graph_transform_box; + let item = h.state().auto.find("timeline.graph.more.transformBox").unwrap().rect; + click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0)); + assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button"); +} From 3bde85dfdb738dc1066c92d84b0c617e84b10919 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:48 +0000 Subject: [PATCH 13/28] Timeline: Ctrl/Cmd+wheel and a trackpad pinch zoom time (#428) egui turns Ctrl/Cmd+wheel and a pinch into a zoom factor (zoom_delta) rather than a scroll, and the Timeline only zoomed on Alt+wheel, so both did nothing over the layers. They now zoom time around the pointer, like Alt+wheel. (B stays Set Work Area Start as in After Effects; Split Layer is Ctrl/Cmd+Shift+D.) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/timeline.rs | 9 ++++++-- crates/ui-egui/tests/ui_timeline_zoom.rs | 28 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 151495604..ef2d40fe5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1614,9 +1614,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); - if zoom && dy.abs() > 0.0 { + // Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll + // (#428); like Alt+wheel they zoom time. + let pinch = ui.input(|i| i.zoom_delta()) as f64; + let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); + if (pinch - 1.0).abs() > 1e-4 { + set_zoom(&mut app.ui.timeline, &comp, tm.pps * pinch, fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); + } else if zoom && dy.abs() > 0.0 { // Alt+wheel zooms around the pointer, out until the whole comp shows. - let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); set_zoom(&mut app.ui.timeline, &comp, tm.pps * (dy as f64 / 200.0).exp(), fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); } else { let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0); diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 48b19cb8f..5fb169437 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -59,6 +59,34 @@ fn alt_wheel_zooms_out_until_the_whole_comp_shows() { assert!(h.state().ui.timeline.pps.is_some()); } +/// #428: Ctrl/Cmd+wheel and a trackpad pinch zoom time over the layers, as Alt+wheel does (egui +/// hands them over as a zoom factor, which the Timeline ignored). +#[test] +fn ctrl_wheel_and_pinch_zoom_time() { + let (mut h, layer) = harness(); + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(2); + let pps = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.pps.unwrap_or(0.0); + let start = pps(&h); + assert!(start > 0.0); + let at = over_bar(&h, layer); + h.event(Event::PointerMoved(at)); + h.event(Event::Zoom(0.5)); + h.step(); + let pinched = pps(&h); + assert!(pinched > 0.0 && pinched < start * 0.75, "a pinch in zooms out: {start} -> {pinched}"); + let at = over_bar(&h, layer); + h.event(Event::PointerMoved(at)); + h.event(Event::ModifiersChanged(Modifiers::COMMAND)); + h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 240.0), modifiers: Modifiers::COMMAND, phase: egui::TouchPhase::Move }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + assert!(pps(&h) > pinched, "Ctrl/Cmd+wheel up zooms in: {pinched} -> {}", pps(&h)); + // Over the layers, so the rows did not scroll. + assert_eq!(h.state().ui.timeline.scroll_y, 0.0); +} + fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) From e96638093c60e15f36f6eab400e1ecbe424c0cb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:32:18 +0000 Subject: [PATCH 14/28] Workspaces: saved workspaces last between sessions (#320) Window > Workspace > Save as New Workspace and Save Changes to this Workspace kept the layout only in memory (`UiState::saved_workspaces` / `saved_floating`): nothing wrote them, so they were gone after a restart. They are now written to `workspaces.json` in the settings store (the config folder on desktop, OPFS on the web) when a workspace is saved, renamed or deleted, and read back when the app starts; a file that doesn't read is ignored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/lib.rs | 39 +++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 4 ++++ crates/ui-egui/tests/ui_panels.rs | 36 ++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd0efc0ca..1ca35f268 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -295,6 +295,17 @@ pub struct EffectcraftApp { pub(crate) template_list: Option<(Vec, Vec)>, } +/// The saved workspaces in the settings store ([`effectcraft_engine::config::ConfigStore`]). +pub const WORKSPACES_FILE: &str = "workspaces.json"; + +#[derive(serde::Serialize, serde::Deserialize)] +struct SavedWorkspaces { + #[serde(default)] + workspaces: std::collections::BTreeMap, + #[serde(default)] + floating: std::collections::BTreeMap>, +} + impl EffectcraftApp { pub fn new(session: Session) -> Self { EffectcraftApp { @@ -353,6 +364,34 @@ impl EffectcraftApp { template_list: None, } .with_ui_commands() + .with_saved_workspaces() + } + + /// The workspaces saved in an earlier session (Window ▸ Workspace ▸ Save as New Workspace, + /// Save Changes to this Workspace), from the settings store. A file that doesn't read is + /// ignored. + fn with_saved_workspaces(mut self) -> Self { + let text = self.session.config.as_ref().and_then(|c| c.read(WORKSPACES_FILE)); + match text.map(|t| serde_json::from_str::(&t)) { + Some(Ok(w)) => { + self.ui.saved_workspaces = w.workspaces; + self.ui.saved_floating = w.floating; + } + Some(Err(e)) => log::warn!("{WORKSPACES_FILE}: {e}"), + None => {} + } + self + } + + /// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After + /// Effects' saved workspaces last until they are deleted (#320). + pub(crate) fn store_saved_workspaces(&self) { + let Some(c) = &self.session.config else { return }; + let w = SavedWorkspaces { workspaces: self.ui.saved_workspaces.clone(), floating: self.ui.saved_floating.clone() }; + let written = serde_json::to_string(&w).map_err(std::io::Error::other).and_then(|t| c.write(WORKSPACES_FILE, &t)); + if let Err(e) = written { + log::warn!("{WORKSPACES_FILE}: {e}"); + } } /// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7851779ff..6b21c0c50 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -943,6 +943,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu let name = app.ui.workspace.clone(); app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); + app.store_saved_workspaces(); json!({"workspace": name}) } "window.saveWorkspaceAs" => { @@ -959,6 +960,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); app.ui.workspace = name.clone(); + app.store_saved_workspaces(); json!({"workspace": name}) } "window.editWorkspaces" => { @@ -983,6 +985,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.saved_workspaces.remove(&name).is_none() && !builtin { return Err(format!("no workspace `{name}`")); } + app.store_saved_workspaces(); if app.ui.workspace == name { app.set_workspace("Default"); } @@ -1000,6 +1003,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.workspace == name { app.ui.workspace = new.to_string(); } + app.store_saved_workspaces(); return Ok(json!({"renamed": new})); } json!({"workspaces": app.workspace_names()}) diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146d..920e49480 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -117,6 +117,42 @@ fn hover(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.run_steps(3); } +/// Issue #320: saved workspaces last between sessions (they were only kept in memory): a new +/// app on the same settings store lists them and brings their layout back; renamed and deleted +/// ones stay that way. +#[test] +fn saved_workspaces_persist_across_sessions() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + use effectcraft_ui_egui::menus::invoke; + let store = std::sync::Arc::new(MemoryConfig::default()); + let launch = || { + let s = Session { config: Some(store.clone()), ..Default::default() }; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(2); + h + }; + let mut h = launch(); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Minimal"})).unwrap(); + invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "My Layout"})).unwrap(); + invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "Scratch"})).unwrap(); + let saved = h.state().ui.dock.clone(); + drop(h); + let mut h = launch(); + assert_eq!(h.state().saved_workspace_names(), ["My Layout", "Scratch"]); + assert_eq!(h.state().ui.workspace, "Default"); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "My Layout"})).unwrap(); + assert_eq!(h.state().ui.dock, saved); + invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "Scratch", "delete": true})).unwrap(); + invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "My Layout", "rename": "Edit Bay"})).unwrap(); + drop(h); + assert_eq!(launch().state().saved_workspace_names(), ["Edit Bay"]); + // A damaged file is ignored. + store.write(effectcraft_ui_egui::WORKSPACES_FILE, "{not json").unwrap(); + assert!(launch().state().saved_workspace_names().is_empty()); +} + /// Issue #191: a workspace saved with Save as New Workspace is listed in Window ▸ Workspace /// (below the built-ins, where the submenu used to be cut off) and choosing it there brings its /// layout back. From fbe0f7e1ce6a9e35720a7279fb43c8b5bd314a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:32:18 +0000 Subject: [PATCH 15/28] Cache bars: cached frames show at 1/3 resolution (#595) The cache bars count the frames of `shown_series`, which rebuilds the series from the frame key's scale in thousandths (333 -> 0.333). The key's `opts` hashed the render options with the f64 scale, and the frames had been rendered at 0.3333..., so the series never matched and the bars stayed empty at Third (and any resolution that isn't a whole number of thousandths, such as Custom 1/7). `opts_hash` now hashes the scale rounded to the key's thousandths, which the key carries anyway. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/frames.rs | 5 ++++- crates/ui-egui/tests/ui_ram_preview.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 55c75f0b8..33fd676d2 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -280,8 +280,11 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc h.finish() } -/// Hash of render options (part of [`FrameKey`] and of the disk key). +/// Hash of render options (part of [`FrameKey`] and of the disk key). The scale counts as the +/// key's thousandths ([`FrameKey::scale`]): a series rebuilt from the key (the cache bars) has +/// the same options as the frames rendered at 1/3 = 0.333… (#595). pub fn opts_hash(o: &RenderOpts) -> u64 { + let o = RenderOpts { scale: (o.scale * 1000.0).round() / 1000.0, ..*o }; let mut h = disk_cache::Hash128::default(); h.write(format!("{o:?}").as_bytes()); h.finish() as u64 diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/ui_ram_preview.rs index 84610b7cd..5459be8cc 100644 --- a/crates/ui-egui/tests/ui_ram_preview.rs +++ b/crates/ui-egui/tests/ui_ram_preview.rs @@ -125,3 +125,23 @@ fn prefetch_waits_while_edits_keep_coming() { let series = h.state().shown_series(ItemId(c)); assert!(h.state().frames.cached_frames(&series).len() > 1, "prefetch resumed"); } + +/// #595: at Third (and other resolutions that aren't a whole number of thousandths) the cache +/// bars find the rendered frames. The series they count was rebuilt from the frame key's scale +/// (333 → 0.333), whose render options hashed apart from the frames rendered at 0.3333…. +#[test] +fn cached_frames_are_counted_at_one_third_resolution() { + use effectcraft_ui_egui::state::Resolution; + for res in [Resolution::Third, Resolution::Custom(7)] { + let mut s = Session::default(); + let c = s.execute("comp.new", json!({"name": "Third", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.viewer.res = res; + settle(&mut h); + let series = h.state().shown_series(ItemId(c)); + assert_eq!(series.scale, (res.scale(1.0, 1.0) * 1000.0).round() as u32, "{res:?}"); + assert!(shown_cached(&h, c), "{res:?}: the shown frame counts as cached"); + assert!(!h.state().frames.cached_frames(&series).is_empty(), "{res:?}"); + } +} From 7e0e641606efe5f78b7496a4ffe3888293539166 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:30:46 +0000 Subject: [PATCH 16/28] Edit: Copy with a property selected copies that property, and Paste sets it on the selected layers (#493) Cause: edit.copy handled text editing, selected keyframes, effects and shape items, then fell back to copying the selected layers. Selecting a property selects its keys, so an animated property copied, but an unanimated one (or the Transform group, whose keys prop.select doesn't select) copied the whole layer, and Paste added a duplicate layer instead of setting the property. Fix: with properties (or the Transform group) selected, Edit > Copy copies, as After Effects does, each animated property's keys (the selected ones, else all of them, through keys.copy) and each unanimated property's value into a new value clipboard keyed by match path. Edit > Paste sets those values on the same properties of the selected layers (a key at the CTI where the target is animated) and pastes the keys at the CTI, as one undo step (keys.paste now honours `merge`). Keys alone still answer with their count, as before. Test: copy_a_selected_property_pastes_onto_the_same_property (static Position, then the Transform group with animated Opacity: values and keys land on layer B, no layer is added, one undo step each). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit 8267519ebe20e889196e538de23b85b04a1fa76a) --- crates/engine/src/commands/anim.rs | 2 +- crates/engine/src/commands/edit.rs | 110 ++++++++++++++++++++++++++- crates/engine/src/lib.rs | 4 + crates/engine/src/tests_menu_cmds.rs | 47 ++++++++++++ 4 files changed, 160 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3d087e074..3ea27b0df 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -105,7 +105,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { if plan.is_empty() { return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)")); } - s.edit("Paste Keyframes", None, |proj, st| { + s.edit("Paste Keyframes", merge_p(p), |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut sel = vec![]; for (lid, uid, ci) in &plan { diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 45ab1395a..e5d2f912c 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -6,7 +6,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p}; -use crate::{EngineError, LinkClip, Result, Session, cmd}; +use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd}; fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.history.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) } @@ -25,6 +25,7 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> { && s.state.effect_clipboard.is_empty() && s.state.contents_clipboard.is_empty() && s.state.link_clipboard.is_none() + && s.state.value_clipboard.is_empty() { Err("the clipboard is empty".into()) } else { @@ -210,14 +211,21 @@ fn clear_clipboards(s: &mut Session) { s.state.effect_clipboard.clear(); s.state.contents_clipboard.clear(); s.state.link_clipboard = None; + s.state.value_clipboard.clear(); s.state.clip_is_keys = false; } -/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers. +/// Edit ▸ Copy: selected properties copy their keyframes and values, selected keyframes go +/// to the keyframe clipboard, else layers. fn copy(s: &mut Session, p: &Value) -> Result { if s.state.text_edit.is_some() && p.get("layers").is_none() { return super::text_edit::copy(s); } + if p.get("layers").is_none() + && let Some(r) = copy_properties(s)? + { + return Ok(r); + } // Keyframes selected → copy keys (pasted at the CTI). if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { s.state.link_clipboard = None; @@ -247,6 +255,101 @@ fn copy(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.clipboard.len())) } +/// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After +/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and +/// an unanimated one its value, for Paste onto the same properties of other layers (#493). +/// `None` when no property (or Transform group) is selected. +fn copy_properties(s: &mut Session) -> Result> { + let Some(comp) = s.active_comp() else { return Ok(None) }; + let mut leaves = vec![]; + for (lid, uid) in &s.state.selected_props { + let Some(l) = comp.layer(*lid) else { continue }; + if l.props.find(*uid).is_some() { + leaves.push((*lid, *uid)); + } else if let Some(g) = l.props.find_group(*uid) + && l.props.match_path_of(*uid).as_deref() == Some("transform") + { + g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid))); + } + } + if leaves.is_empty() { + return Ok(None); + } + let mut keys = s.state.selected_keys.clone(); + let mut values = vec![]; + for (lid, uid) in &leaves { + let Some(l) = comp.layer(*lid) else { continue }; + let Some(pr) = l.props.find(*uid) else { continue }; + if pr.keys.is_empty() { + if let Some(path) = l.props.match_path_of(*uid) + && !values.iter().any(|(p, _)| *p == path) + { + values.push((path, pr.value.clone())); + } + } else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) { + keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); + } + } + clear_clipboards(s); + let mut n = 0; + if !keys.is_empty() { + let selected = std::mem::replace(&mut s.state.selected_keys, keys); + let r = s.execute("keys.copy", json!({})); + s.state.selected_keys = selected; + n = r?.as_u64().unwrap_or(0); + } + let v = values.len(); + s.state.value_clipboard = values; + // Keys alone answer as `keys.copy` does (their count). + Ok(Some(if v == 0 { json!(n) } else { json!({"keys": n, "values": v}) })) +} + +/// Paste copied property values (and keyframes, at the CTI) onto the same properties of the +/// selected layers, as one undo step. +fn paste_properties(s: &mut Session, p: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let ids = s.state.selected_layers.clone(); + if ids.is_empty() { + return Err(EngineError::Other("select a layer to paste into".into())); + } + let values = s.state.value_clipboard.clone(); + let keys = s.state.clip_is_keys && !s.state.key_clipboard.is_empty(); + let t = s.time(); + // The values and the keys are one step: the keys' paste merges into the values' step. + let merge = "edit.paste.properties"; + s.history.merge_key = None; + let n = s.edit("Paste", keys.then_some(merge), |proj, _| { + let mut n = 0; + for lid in &ids { + let l = super::layer_mut(proj, cid, *lid)?; + if l.switches.locked { + continue; + } + let lt = l.layer_time(t); + for (path, v) in &values { + if let Some(pr) = l.props.prop_mut(path) + && let Some(v) = pr.value.coerce_json(&v.to_json()) + { + pr.set_value_at(lt, v); + n += 1; + } + } + } + Ok(n) + })?; + let mut r = json!({"values": n}); + if keys { + let mut kp = p.clone(); + if let Some(o) = kp.as_object_mut() { + o.insert("merge".into(), json!(merge)); + } + let pasted = s.execute("keys.paste", kp); + s.history.merge_key = None; + r["keys"] = pasted?; + } + Ok(r) +} + /// The expression accessor for a property, e.g. `comp("Main").layer("Solid").transform("Position")`. pub(crate) fn link_expression(s: &Session, lid: LayerId, uid: effectcraft_project::Uid, relative: bool) -> Option { let cid = s.active_comp_id()?; @@ -392,6 +495,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { if let Some(clip) = s.state.link_clipboard.clone() { return paste_links(s, clip); } + if !s.state.value_clipboard.is_empty() { + return paste_properties(s, p); + } if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() { return s.execute("keys.paste", p.clone()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d09cbf2b4..eae96a81d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -216,6 +216,10 @@ pub struct EditorState { /// The last copy was keyframes (Paste pastes keys at the CTI). #[serde(skip)] pub clip_is_keys: bool, + /// Values of selected unanimated properties (match path, value), copied with Edit ▸ Copy; + /// Paste sets the same properties of the selected layers. + #[serde(skip)] + pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, /// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the /// selected layers. #[serde(skip)] diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index df0af369d..c7252acb3 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -346,6 +346,53 @@ fn keyframe_clipboard_paste_and_paste_reversed() { assert_eq!((keys[0].value.as_f64(), keys[1].value.as_f64()), (0.0, 100.0)); } +/// #493: Edit ▸ Copy with a property selected copies that property (its value when it isn't +/// animated, its keys when it is), and Paste sets the same property of the selected layer, as +/// in After Effects, instead of copying the whole layer. +#[test] +fn copy_a_selected_property_pastes_onto_the_same_property() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let n_layers = s.active_comp().unwrap().layers.len(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "transform/position"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.state.clipboard.is_empty(), "the layer isn't copied"); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), n_layers); + assert_eq!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]); + assert_eq!(layer(&s, b).props.prop("transform/rotation").unwrap().value.as_f64(), 0.0, "only the copied property"); + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]); + assert_eq!(s.history.undo.len(), undo); + // The Transform group with an animated Opacity: its keys (at the CTI) and the other values, + // in one undo step. + s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap(); + let transform = layer(&s, a).props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + let l = layer(&s, b); + let keys = &l.props.prop("transform/opacity").unwrap().keys; + assert_eq!(keys.len(), 2); + assert!((keys[0].time.seconds() - 2.0).abs() < 1e-6 && keys[1].value.as_f64() == 0.0); + assert_eq!(l.props.prop("transform/rotation").unwrap().value.as_f64(), 30.0); + assert_eq!(s.active_comp().unwrap().layers.len(), n_layers); + s.execute("edit.undo", json!({})).unwrap(); + let l = layer(&s, b); + assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty() && l.props.prop("transform/rotation").unwrap().value.as_f64() == 0.0); +} + #[test] fn property_links_and_expression_only() { let mut s = comp(); From 77dfd44658421e6107b39d1f51d87bde586f25ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:30:46 +0000 Subject: [PATCH 17/28] Puppet: a pin added to a bent mesh holds it where it is; nothing jumps (#318) Cause: puppet::solve binds each moving pin to its nearest mesh vertex and fixes that vertex at position + (vertex - rest), an offset in rest space with no rotation or scale. puppet.addPin set a new pin's position to the click, so in a bent region the bound vertex was pulled to where it would be unrotated, and the mesh jumped as soon as the pin was added. Fix: addPin picks the position that keeps the bound vertex at its current deformed place: the vertex's deformed position minus the rest offset. On an unbent mesh that is the click itself (round-off is snapped back to it), so existing pins behave as before. Test: a_pin_added_to_a_bent_mesh_moves_nothing (two pins, one moved to bend the layer, then pins added at three places on the bent part: at most a few edge pixels change from solver round-off; before the fix about 250 did). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit 16151203b304d565b425873fdd33ae082cae483b) --- crates/engine/src/commands/puppet.rs | 32 ++++++++++++++++++++++------ crates/engine/src/tests_paint.rs | 22 +++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index a333031e1..320e1d415 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -21,6 +21,10 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, merge_p, str_p}; use crate::{EngineError, Result, Session, cmd}; +fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 { + (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) +} + fn pt(v: Option<&Value>) -> Option<[f64; 2]> { let a = v?.as_array()?; Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) @@ -77,7 +81,8 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { let fx_uid = find_puppet(s, cid, lid); let want_mesh = p.get("mesh").and_then(Value::as_u64); let new_mesh = p.get("newMesh").and_then(Value::as_bool).unwrap_or(false); - let mut target: Option<(Uid, [f64; 2])> = None; + // (mesh, the pin's rest point, where it is now) + let mut target: Option<(Uid, [f64; 2], [f64; 2])> = None; if let Some(fu) = fx_uid && !new_mesh && let Some((buf, params)) = puppet_eval(s, cid, lid, fu, t) @@ -90,7 +95,22 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { let inside = puppet::contains(mesh, def, at); let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0; if inside || near || want_mesh.is_some() { - target = Some((*uid, puppet::unmap(mesh, def, at))); + let rest = puppet::unmap(mesh, def, at); + // A moving pin holds its nearest mesh vertex at `position + (vertex - rest)` + // (puppet::solve). Choose the position that keeps that vertex where it is now, so + // adding a pin to a bent mesh moves nothing (After Effects, #318); on an unbent + // mesh that is the click itself. + let now = (0..mesh.verts.len().min(def.len())) + .filter(|_| kind.moves()) + .min_by(|a, b| dist2(mesh.verts[*a], rest).total_cmp(&dist2(mesh.verts[*b], rest))) + .and_then(|v| { + let (r, d) = (mesh.verts.get(v)?, def.get(v)?); + Some([d[0] - (r[0] - rest[0]), d[1] - (r[1] - rest[1])]) + }) + // Round-off only (an unbent mesh): exactly the click. + .filter(|n| dist2(*n, at) > 1e-12) + .unwrap_or(at); + target = Some((*uid, rest, now)); break; } } @@ -131,14 +151,14 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { } }; let pg = fxg.find_group_mut(fx_uid).ok_or_else(|| bad(cmd, "Puppet effect gone"))?; - let (mesh_uid, rest) = match target { - Some((m, r)) => (m, r), + let (mesh_uid, rest, now) = match target { + Some(t) => t, None => { let n = puppet::meshes(pg).count(); let g = puppet::mesh_group(&mut ids, &format!("Mesh {}", n + 1), at, &opts); let u = g.uid; pg.children.push(g.into()); - (u, at) + (u, at, at) } }; let name = puppet::next_pin_name(pg, kind); @@ -146,7 +166,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { let mut pin = puppet::pin_group(&mut ids, &name, kind, rest); if let Some(pos) = pin.get_mut("position") { if kind.moves() { - pos.value = KV::Vec2(at); + pos.value = KV::Vec2(now); pos.set_animated(true, lt); } else { pos.value = KV::Vec2(rest); diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs index 4e05027af..db831ca96 100644 --- a/crates/engine/src/tests_paint.rs +++ b/crates/engine/src/tests_paint.rs @@ -203,6 +203,28 @@ fn first_puppet_pin_leaves_the_layer_still() { } } +/// #318: a pin added to an already-bent mesh holds the mesh where it is: nothing jumps. +#[test] +fn a_pin_added_to_a_bent_mesh_moves_nothing() { + let (mut s, id) = setup(); + s.execute("puppet.addPin", json!({"layer": id, "position": [5, 30]})).unwrap(); + s.execute("puppet.addPin", json!({"layer": id, "position": [95, 30]})).unwrap(); + s.set_time(Tick::from_seconds_f64(1.0)); + s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [80, 70]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let t = Tick::from_seconds_f64(1.0); + let before = s.render(cid, t, RenderOpts::default()); + for at in [[50, 54], [30, 42], [70, 62]] { + s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap(); + let after = s.render(cid, t, RenderOpts::default()); + let moved = after.data.iter().zip(&before.data).filter(|(a, b)| (a[3] - b[3]).abs() > 0.25).count(); + // (Before #318 the pin at [50, 54] moved about 250 edge pixels; the solver's round-off + // still touches a few.) + assert!(moved <= 16, "a pin at {at:?} moved {moved} pixels"); + s.undo(); + } +} + #[test] fn puppet_pin_kinds_and_mesh_options() { let (mut s, id) = setup(); From 9b7f8cf005a9055538b992889e27c0eee49fcc89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:30:54 +0000 Subject: [PATCH 18/28] Effects: Write-on draws a stroke along the animated brush, honouring Stroke Length and Brush Spacing (#536) Cause: write_on_plan drew a single dab at the current Brush Position. Stroke Length, Brush Spacing and the Paint/Brush Time Properties were declared but unused, so an animated brush showed a moving dot that dropped every earlier mark. Fix: as After Effects describes Write-on, a dab is laid every Brush Spacing seconds from the layer's start up to the current time, each lasting Stroke Length seconds (0: for ever). Their positions come from the host's parameters at their times (EffectHost::params_at, which the renderer implements). Paint Time Properties > Opacity and Brush Time Properties > Size (or Size & Hardness) keep each dab's own opacity / size; otherwise the current values apply to all. One frame draws at most 4096 dabs (longer strokes space them out and join them). Without a host the current dab draws, as before. Colour and hardness stay one per stroke (the paint plan has one colour and hardness), so Paint Time Properties > Color and per-dab hardness aren't modelled. The GPU path uses the same plan. Test: write_on_accumulates_a_stroke_along_the_animated_brush (the report's fixture: start and midpoint marks stay at 0.5 s with the end still clear, all marks at 1 s, Stroke Length 0.25 keeps only the recent trail, Brush Spacing 0.5 gives three separate marks, per-dab opacity). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit c82c9aeb685eab0cb36ca69c0b337781f4bad258) --- crates/effects/src/generate3.rs | 128 ++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 926846a9b..eaa924119 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul}; -use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; +use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } @@ -1074,23 +1074,66 @@ fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { // ---------------------------------------------------------------- Write-on / Glue Gun -/// Brush dab history: only the brush position at the current time is known to an effect -/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along -/// the current position — a single dab when the brush is static. Deterministic. +/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every +/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length +/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times; +/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own +/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per +/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic. fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { let plan = write_on_plan(ctx, &b); draw_paint(&mut b, &plan); b } +/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined. +const WRITE_ON_MAX_DABS: i64 = 4096; + fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { let pr = ctx.params; - let q = b.to_px(pr.v2("brushPosition")); - let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05); + let own_opacity = pr.e("paintTimeProperties") == 1; + let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3); + let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32; + let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05); + let dab = |p: &Params| { + let q = b.to_px(p.v2("brushPosition")); + ([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 }) + }; + let mut segs = vec![]; + let t = ctx.time; + let spacing = pr.f("brushSpacing").max(0.001); + let life = pr.f("strokeLength").max(0.0); + if let Some(host) = ctx.env.host + && t.is_finite() + && t >= 0.0 + { + let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 }; + let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64; + let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64; + let n = k1.saturating_sub(k0).saturating_add(1); + let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1); + let mut prev: Option<[f64; 2]> = None; + let mut k = k0; + while k <= k1 { + let tk = k as f64 * spacing; + let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) }; + if let Some((q, r, v)) = at { + // Dabs laid further apart than the brush spacing are joined into a line. + let a = if stride > 1 { prev.unwrap_or(q) } else { q }; + segs.push(Seg { a, b: q, r, v }); + prev = Some(q); + } + k = k.saturating_add(stride); + } + } + if segs.is_empty() { + let (q, r, v) = dab(pr); + segs.push(Seg { a: q, b: q, r, v }); + } PaintPlan { - cov: Coverage::Segs { segs: vec![Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], hardness: pr.f("brushHardness") / 100.0 }, + cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 }, color: pr.color("color"), - opacity: (pr.f("brushOpacity") / 100.0) as f32, + opacity: if own_opacity { 1.0 } else { opacity(pr) }, style: pr.e("paintStyle"), } } @@ -2204,6 +2247,75 @@ mod tests { assert_eq!(g.img.get(0, 0), src.get(0, 0)); } + /// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires + /// after Stroke Length, and lays its dabs Brush Spacing seconds apart. + #[test] + fn write_on_accumulates_a_stroke_along_the_animated_brush() { + // Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px. + struct Brush(Vec<(&'static str, Value)>); + impl Brush { + fn at(&self, t: f64) -> Vec<(&'static str, Value)> { + let mut v = self.0.clone(); + v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0]))); + v + } + } + impl EffectHost for Brush { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let s = crate::find("ec.generate.writeon").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in self.at(t) { + p.values.insert(k.to_string(), v); + } + Some(p) + } + } + let render = |extra: &[(&'static str, Value)], t: f64| { + let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))]; + set.extend(extra.iter().cloned()); + let host = Brush(set); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let at = host.at(t); + run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img + }; + let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9; + let half = render(&[], 0.5); + assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56)); + let end = render(&[], 1.0); + assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x))); + // Stroke Length 0.25 s: only the last quarter second's trail. + let trail = render(&[("strokeLength", num(0.25))], 1.0); + assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56)); + // Brush Spacing 0.5 s: three separate marks. + let spaced = render(&[("brushSpacing", num(0.5))], 1.0); + assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44)); + // Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with. + struct Fade; + impl EffectHost for Fade { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?; + p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 })); + Some(p) + } + } + let p1 = Fade.params_at(1.0).unwrap(); + let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img; + assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16)); + } + #[test] fn paint_bucket_fills_enclosed_region_only() { let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]); From 86d47dc3fe3467bed58dc4facf1c815a02d70748 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:31:40 +0000 Subject: [PATCH 19/28] Snapping and Align: shape layers meet at their visual edges, with no gap (#352) Cause: render::content_bounds (used by snapping's targets and source, Align, Distribute, the viewer's bounding box and handles) took shape layers' bounds from draw_bounds, the rasteriser's safety rectangle: 1 px of anti-aliasing padding around fills and width x miter limit / 2 + 1 around strokes. Two squares snapped edge to edge therefore stayed 2 px apart, or much more with strokes. Fix: shapes::content_bounds uses visual bounds, as After Effects' layer bounds do: the path bounds plus half the stroke width (through the draw's group scale), with no anti-aliasing or miter padding. Rendering keeps draw_bounds. Tests: render shape_content_bounds_are_the_visual_edges (a 100 x 100 rectangle is exactly +-50, +-55 with a 10 px stroke); engine shape_layers_align_by_their_visual_edges (widths 100 / 110, Align Left puts both edges exactly at 0). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit bfd0abd7437ad1d38652e3aef51f9ba5dea9100b) --- crates/engine/src/commands/align.rs | 21 +++++++++++++++++++++ crates/render/src/shapes.rs | 22 ++++++++++++++++++---- crates/render/src/tests.rs | 23 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands/align.rs b/crates/engine/src/commands/align.rs index c225b1e63..2c2b34588 100644 --- a/crates/engine/src/commands/align.rs +++ b/crates/engine/src/commands/align.rs @@ -265,6 +265,27 @@ mod tests { assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err()); } + /// #352: shape layers align and snap by their visual edges (half the stroke, no + /// anti-aliasing padding), so two rectangles lined up edge to edge touch with no gap. + #[test] + fn shape_layers_align_by_their_visual_edges() { + let mut s = session(); + let shape = |s: &mut Session, extra: Value| { + let mut p = json!({"kind": "rect", "size": [100, 100], "fill": "#ff0000"}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("layer.newShape", p).unwrap()["layer"].as_u64().unwrap() + }; + let a = shape(&mut s, json!({})); + let b = shape(&mut s, json!({"stroke": "#ffffff", "strokeWidth": 10})); + let width = |s: &Session, id| { + let r = bounds(s, id); + r[2] - r[0] + }; + assert!((width(&s, a) - 100.0).abs() < 1e-6 && (width(&s, b) - 110.0).abs() < 1e-6, "{} {}", width(&s, a), width(&s, b)); + s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap(); + assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6); + } + #[test] fn align_honours_parent_scale_and_rotation() { let mut s = session(); diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 6bd8686bf..e3cbca4a1 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -517,6 +517,20 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option { Some(b.inflate(grow, grow)) } +/// Local-space visual bounds of a draw, as After Effects' layer bounds (`sourceRectAtTime` with +/// extents): the path bounds, plus half the stroke width for strokes. No anti-aliasing or miter +/// padding, so snapping and Align put edges exactly together (#352). +fn visual_bounds(d: &Draw, paths: &[BezPath]) -> Option { + let b = effectcraft_path::bounds(paths)?; + let stroke = match &d.paint { + Paint::Stroke { style, .. } => Some(style), + Paint::Gradient { stroke: Some(style), .. } => Some(style), + _ => None, + }; + let grow = stroke.map_or(0.0, |style| style.width * 0.5 * d.xf.mean_scale()); + Some(if grow.is_finite() && grow > 0.0 { b.inflate(grow, grow) } else { b }) +} + /// Maximum number of coverage pixels rasterised concurrently (bounds peak memory). const BATCH_PIXELS: usize = 8 << 20; @@ -639,16 +653,16 @@ pub fn stroke_region(paths: &[BezPath], style: &StrokeStyle) -> Option (!r.elements().is_empty()).then_some(r) } -/// Layer-space bounds of a shape layer's painted contents (strokes included), without -/// rasterising. +/// Layer-space visual bounds of a shape layer's painted contents (half the stroke width +/// included, no anti-aliasing padding), without rasterising. pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); draws .iter() .filter_map(|d| { - let paths: Vec = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect(); - draw_bounds(d, &paths) + let paths: Vec = d.slots.iter().filter_map(|&i| arena.get(i).cloned()).filter(|p| !p.elements().is_empty()).collect(); + visual_bounds(d, &paths) }) .reduce(|a, b| a.union(b)) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 730c5b899..7e0653914 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -808,3 +808,26 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() { assert_same(&render_cached(&p, cid, Tick::ZERO, Some(&cache)), &fresh, "precomp source edit"); assert_ne!(before.get(50, 50), fresh.get(50, 50)); } + +/// #352: a shape layer's bounds (snapping, Align) are its visual edges: a 100×100 rectangle is +/// exactly ±50, and a 10 px stroke adds half its width, with no anti-aliasing padding. +#[test] +fn shape_content_bounds_are_the_visual_edges() { + for (stroke, half) in [(None, 50.0), (Some(10.0), 55.0)] { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let mut items = vec![build::shape_rect(&mut ids, [100.0, 100.0], [0.0, 0.0], 0.0), build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0])]; + if let Some(w) = stroke { + items.push(build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], w)); + } + let g = build::shape_group(&mut ids, "Rectangle 1", items); + p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); + let b = crate::content_bounds(&ctx, &l).unwrap(); + assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}"); + } +} From 40e87709d56c43848ca7b9eb6acb1499787966e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:28:59 +0000 Subject: [PATCH 20/28] Import: numbered stills import as stills unless the Sequence option is on; dropped folders make sequences (#431) Cause: SequenceOptions::from_params defaulted `sequence` to true for file.import, so any numbered still (IMG_0001.jpg) brought its whole numbered run from its folder, and several picked or dropped stills with a common prefix became one sequence. Dropped files never asked, and the Import dialog's " Sequence" checkbox started ticked. Fix, matching After Effects: file.import takes picked and dropped files as stills unless `sequence: true` (the Import dialog's Sequence checkbox, which now starts unticked; Force Alphabetical Order implies it), while the numbered stills inside a picked or dropped folder still import as sequences (SequenceOptions::folders), and `sequence: false` (Alt-drag of a folder) imports a folder's files one by one. Replace Footage, reload, proxies and render outputs keep detecting their sequence (SequenceOptions::default and from_params are unchanged), and mediaBrowser.import keeps bringing a numbered still's run unless `sequence: false`. docs/footage.md and docs/agents.md describe the new defaults. Tests: sequence import_takes_stills_unless_asked_for_a_sequence and engine numbered_stills_import_as_stills_by_default (one dropped still and two picked IMG_ stills stay stills, a dropped folder makes one sequence, the checkbox brings the run); the existing sequence tests pass `sequence: true`; ui import_dialog_offers_the_image_sequence_option (unticked by default: a still; ticked: the run) and a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation (two dropped frames: two stills; the folder: one sequence). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit aa3c31db9ab3a774ee2f8ba867e1bafc1fce7aba) --- crates/engine/src/commands/file.rs | 4 +- crates/engine/src/commands/panels_cmds.rs | 7 ++- crates/engine/src/sequence.rs | 69 +++++++++++++++++------ crates/engine/src/tests_sequence.rs | 26 +++++++-- crates/ui-egui/src/panels/forms.rs | 7 ++- crates/ui-egui/src/prefs_live.rs | 8 ++- crates/ui-egui/tests/ui_drag_drop.rs | 28 +++++---- docs/agents.md | 7 ++- docs/footage.md | 17 ++++-- 9 files changed, 123 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index f0396e5df..fb557de0c 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -376,7 +376,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { } // Numbered stills as image sequences (the Import dialog's " Sequence" and Force // Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage. - let opts = SequenceOptions::from_params(p); + let opts = SequenceOptions::for_import(p); let fps = match p.get("frameRate") { None | Some(Value::Null) => s.prefs.import.sequence_fps, Some(v) => v.as_f64().filter(|x| *x > 0.0 && *x <= 999.0).ok_or_else(|| bad("file.import", "frameRate: frames per second, up to 999"))?, @@ -680,7 +680,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (default true: numbered stills of one run import as one image sequence; one picked file brings its whole run, several picked files that range), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; by default picked files import as stills and folders' numbered stills as sequences; false imports a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/commands/panels_cmds.rs index bcdb7a734..40241e6f4 100644 --- a/crates/engine/src/commands/panels_cmds.rs +++ b/crates/engine/src/commands/panels_cmds.rs @@ -105,7 +105,10 @@ fn browser_import(s: &mut Session, p: &Value) -> Result { return Ok(json!({"pending": true, "paths": list})); } let target = s.active_comp_id(); - let mut r = s.execute("file.import", json!({"paths": paths}))?; + // A numbered still picked in the browser still brings its image sequence (`sequence: false` + // for the picked stills alone); `file.import` alone imports stills. + let sequence = p.get("sequence").and_then(Value::as_bool).unwrap_or(true); + let mut r = s.execute("file.import", json!({"paths": paths, "sequence": sequence}))?; // Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped. let errors = super::file::add_to_comp(s, &r, target, p); if !errors.is_empty() @@ -236,7 +239,7 @@ pub fn specs() -> Vec { "Import", [], None, - "{paths, addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}", + "{paths, sequence? (default true: a numbered still brings its image sequence), addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}", browser_enabled, browser_import ), diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/sequence.rs index 221803504..79142955e 100644 --- a/crates/engine/src/sequence.rs +++ b/crates/engine/src/sequence.rs @@ -20,15 +20,17 @@ use crate::{Importer, Services}; /// How an import treats numbered stills. #[derive(Clone, Copy, Debug)] pub struct SequenceOptions { - /// The " Sequence" checkbox: numbered stills of one run import as a sequence. + /// The " Sequence" checkbox: picked numbered stills of one run import as a sequence. pub sequence: bool, + /// The numbered stills inside a picked or dropped folder import as sequences. + pub folders: bool, /// Force Alphabetical Order. pub alphabetical: bool, } impl Default for SequenceOptions { fn default() -> Self { - SequenceOptions { sequence: true, alphabetical: false } + SequenceOptions { sequence: true, folders: true, alphabetical: false } } } @@ -36,7 +38,19 @@ impl SequenceOptions { /// From command parameters `sequence?` (default on) and `alphabetical?`. pub fn from_params(p: &serde_json::Value) -> SequenceOptions { let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); - SequenceOptions { sequence: flag("sequence").unwrap_or(true), alphabetical: flag("alphabetical").unwrap_or(false) } + let sequence = flag("sequence").unwrap_or(true); + SequenceOptions { sequence, folders: sequence, alphabetical: flag("alphabetical").unwrap_or(false) } + } + + /// `file.import`'s options, as in After Effects: picked or dropped files import as stills + /// unless `sequence: true` (the Import dialog's " Sequence" checkbox, which is off + /// unless ticked), while a folder's numbered stills import as sequences unless + /// `sequence: false` (Alt-drag of a folder). + pub fn for_import(p: &serde_json::Value) -> SequenceOptions { + let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); + let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false)); + // Force Alphabetical Order is part of the Sequence option. + SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical } } } @@ -120,28 +134,27 @@ fn run_files(services: &dyn Services, path: &str, alphabetical: bool) -> Vec Vec { - let mut files: Vec = vec![]; + // Each file, and whether it may join a sequence. + let mut files: Vec<(String, bool)> = vec![]; for p in paths { if services.is_dir(p) { let mut inside: Vec = services.list_dir(p).into_iter().filter(|f| matches!(crate::media_browser::kind_of(f), Some(k) if k != "project")).collect(); inside.sort_by_key(|f| file_name(f).to_lowercase()); - files.extend(inside); + files.extend(inside.into_iter().map(|f| (f, opts.folders))); } else { - files.push(p.clone()); + files.push((p.clone(), opts.sequence)); } } - if !opts.sequence { - return files.into_iter().map(Source::File).collect(); - } // Runs in the order their first file was picked. let mut runs: Vec<((String, String, String), Vec)> = vec![]; let mut out: Vec> = vec![]; let mut slot: Vec = vec![]; - for f in files { - match run_key(&f, opts.alphabetical) { + for (f, joins) in files { + match run_key(&f, opts.alphabetical).filter(|_| joins) { Some(key) => match runs.iter_mut().find(|(k, _)| *k == key) { Some((_, run)) => run.push(f), None => { @@ -155,8 +168,10 @@ pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) - } for ((_, mut picked), at) in runs.into_iter().zip(slot) { sort_run(&mut picked, opts.alphabetical); - // One file: its whole run from the folder. - if let [one] = picked.as_slice() { + // One picked file: its whole run from the folder. + if let [one] = picked.as_slice() + && opts.sequence + { let all = run_files(services, one, opts.alphabetical); if all.len() >= 2 { picked = all; @@ -176,7 +191,7 @@ pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) - /// The image sequences importing `paths` would make (the Import dialog asks about them). pub fn sequences(services: &dyn Services, paths: &[String], alphabetical: bool) -> Vec> { - group(services, paths, SequenceOptions { sequence: true, alphabetical }) + group(services, paths, SequenceOptions { sequence: true, folders: true, alphabetical }) .into_iter() .filter_map(|s| match s { Source::Sequence { files, .. } => Some(files), @@ -282,7 +297,10 @@ mod tests { ] ); // Sequence off: every file on its own. - assert_eq!(group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, alphabetical: false }), [Source::File("/s/a_0001.png".into())]); + assert_eq!( + group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, folders: false, alphabetical: false }), + [Source::File("/s/a_0001.png".into())] + ); // A folder: its media files, runs as sequences. let g = group(&t, &strs(&["/s"]), on); assert_eq!(g.len(), 4, "{g:?}"); @@ -290,12 +308,29 @@ mod tests { assert_eq!(g[0].name(), "a_[0001-0010].png"); assert!(!g.iter().any(|s| s.path().ends_with(".txt"))); // Force Alphabetical Order: every PNG in the folder, by name. - let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, alphabetical: true }); + let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, folders: true, alphabetical: true }); assert_eq!(a, [Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png", "/s/b_1.png", "/s/c.png"]), alphabetical: true }]); assert_eq!(a[0].name(), "a_0001.png"); assert_eq!(sequences(&t, &strs(&["/s/a_0001.png", "/s/c.png"]), false).len(), 1); } + /// #431: `file.import` brings picked or dropped numbered stills in as stills; only a folder or + /// an explicit `sequence: true` makes a sequence, as After Effects' Sequence checkbox does. + #[test] + fn import_takes_stills_unless_asked_for_a_sequence() { + let t = Table(vec!["/s/a_0001.png", "/s/a_0002.png", "/s/a_0003.png", "/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]); + let opts = |p: serde_json::Value| SequenceOptions::for_import(&p); + let none = opts(serde_json::json!({"drag": true})); + assert_eq!(group(&t, &strs(&["/s/a_0002.png"]), none), [Source::File("/s/a_0002.png".into())]); + assert_eq!(group(&t, &strs(&["/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]), none).len(), 2); + // A dropped folder: its runs as sequences (Alt-drag: `sequence: false`, files on their own). + assert_eq!(group(&t, &strs(&["/s"]), none).len(), 2); + assert_eq!(group(&t, &strs(&["/s"]), opts(serde_json::json!({"sequence": false}))).len(), 5); + // The Sequence checkbox: one picked file brings its whole run. + let on = group(&t, &strs(&["/s/a_0002.png"]), opts(serde_json::json!({"sequence": true}))); + assert!(matches!(on.as_slice(), [Source::Sequence { files, .. }] if files.len() == 3), "{on:?}"); + } + #[test] fn windows_paths_and_reports() { let t = Table(vec!["C:\\r\\f_01.exr", "C:\\r\\f_02.exr", "C:\\r\\f_05.exr"]); diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs index cd406726b..d59a92983 100644 --- a/crates/engine/src/tests_sequence.rs +++ b/crates/engine/src/tests_sequence.rs @@ -54,7 +54,7 @@ const RUN: [&str; 5] = ["shot_0001.png", "shot_0002.png", "shot_0004.png", "shot fn one_picked_numbered_file_imports_its_run_as_one_sequence() { let dir = folder(&RUN); let mut s = session(); - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")]})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); let (name, f) = footage(&s, &r["items"][0]); assert_eq!(name, "shot_[0001-0005].png"); @@ -75,17 +75,33 @@ fn picking_every_frame_imports_one_sequence() { let dir = folder(&RUN); let mut s = session(); let all: Vec = RUN[..4].iter().map(|n| at(&dir, n)).collect(); - let r = s.execute("file.import", json!({"paths": all, "frameRate": 24})).unwrap(); + let r = s.execute("file.import", json!({"paths": all, "sequence": true, "frameRate": 24})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); let (_, f) = footage(&s, &r["items"][0]); assert_eq!(f.sequence.len(), 4); // `frameRate` sets the sequence's rate on import. assert_eq!((f.frame_rate, f.duration), (FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5))); // A picked range is just that range. - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")]})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")], "sequence": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0004-0005].png", 2)); - assert!(s.execute("file.import", json!({"paths": all, "frameRate": 0})).is_err()); + assert!(s.execute("file.import", json!({"paths": all, "sequence": true, "frameRate": 0})).is_err()); +} + +/// #431: without the Sequence option, picked or dropped numbered stills import as stills (one +/// item each), as in After Effects; a dropped folder still makes its runs sequences. +#[test] +fn numbered_stills_import_as_stills_by_default() { + let dir = folder(&RUN); + let mut s = session(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "drag": true})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_0002.png", FootageKind::Still)); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0004.png")]})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}"); + let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence)); } #[test] @@ -127,7 +143,7 @@ fn force_alphabetical_order_takes_every_image_of_the_type() { fn interpret_footage_start_timecode_relabels_source_time() { let dir = folder(&RUN); let mut s = session(); - let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")]})).unwrap()["items"][0].clone(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 10})).unwrap(); let layer = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); let out = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().out_point; diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 93e051053..dfc00642f 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -154,8 +154,9 @@ fn is_pdf(p: &Value) -> bool { /// File ▸ Import ▸ File… picked numbered stills that import as image sequences: ask, as After /// Effects' " Sequence" checkbox in its Import dialog does (rfd's native dialog can't /// carry a checkbox), with Force Alphabetical Order and the frame rate. `p`: the import's -/// parameters. Returns whether the dialog opened. Dropped files and agents' `file.import` -/// don't ask (`sequence` defaults to on). +/// parameters. Returns whether the dialog opened. The checkbox starts off, as in After Effects: +/// unticked, the picked files import as stills. Dropped files and agents' `file.import` don't +/// ask (files import as stills, folders' runs as sequences). pub fn open_import_sequence(app: &mut EffectcraftApp, p: &Value) -> bool { if has(p, &["sequence", "alphabetical"]) { return false; @@ -187,7 +188,7 @@ fn sequence_fields(app: &EffectcraftApp, runs: &[Vec]) -> Vec { }; vec![ Field::note("note", ¬e), - Field::bool("sequence", &format!("{ext} Sequence"), true), + Field::bool("sequence", &format!("{ext} Sequence"), false), Field::bool("alphabetical", "Force alphabetical order", false), Field::num("frameRate", "Frame rate (fps)", app.session.prefs.import.sequence_fps), Field::note("after", "Change the frame rate, alpha and start frame later with File ▸ Interpret Footage."), diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index ed497dc44..b18d3e874 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -88,7 +88,10 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { None }; - let params = json!({ + // Dropped files import as stills and folders' numbered stills as sequences; Alt-drag + // imports a folder's files one by one (After Effects). + let alt = ctx.input(|i| i.modifiers.alt); + let mut params = json!({ "paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), @@ -96,6 +99,9 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { "position": at, "background": true }); + if alt { + params["sequence"] = json!(false); + } if let Err(e) = crate::menus::invoke(app, &ctx, "file.import", params) { errors.push(crate::i18n::tr_args("Cannot import dropped files: {}", &[&e])); diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 3dee2727e..d9e04ec38 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -408,7 +408,7 @@ fn footage_items(h: &Harness<'_, EffectcraftApp>) -> Vec<(String, usize)> { /// #297: File ▸ Import ▸ File… on one numbered still asks, as After Effects' "PNG Sequence" /// checkbox does, whether to import its whole run as one image sequence (and at what frame -/// rate); unticked, the file imports as a still. +/// rate); unticked (the default, as in After Effects), the file imports as a still. #[test] fn import_dialog_offers_the_image_sequence_option() { let (mut h, _) = harness(); @@ -424,35 +424,41 @@ fn import_dialog_offers_the_image_sequence_option() { assert_eq!(label(&h, "form.field.sequence").as_deref(), Some("PNG Sequence")); assert!(label(&h, "form.field.alphabetical").is_some() && label(&h, "form.field.frameRate").is_some()); assert!(footage_items(&h).is_empty(), "nothing imported before OK"); + // Unticked (the default, #431): the picked file alone, as a still. click_id(&mut h, "form.ok"); step_until(&mut h, |h| !footage_items(h).is_empty()); - assert_eq!(footage_items(&h), [("frame_[0001-0003].png".to_string(), 3)]); + assert_eq!(footage_items(&h), [("frame_0002.png".to_string(), 0)]); - // Unticked: the picked file alone, as a still. + // Ticked: the whole run as one sequence. effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap(); h.run_steps(2); click_id(&mut h, "form.field.sequence"); click_id(&mut h, "form.ok"); step_until(&mut h, |h| footage_items(h).len() == 2); - assert_eq!(footage_items(&h)[1], ("frame_0002.png".to_string(), 0)); + assert_eq!(footage_items(&h)[1], ("frame_[0001-0003].png".to_string(), 3)); let _ = std::fs::remove_dir_all(&dir); } -/// #297: every frame of a sequence dropped on the window arrives as one image sequence (it used -/// to make one item per frame), and Interpret Footage shows its frames and Start Timecode (missing frames -/// always show colour bars, as in After Effects: there is no Missing Frames choice). +/// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret +/// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in +/// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one +/// item each, as in After Effects. #[test] -fn dropped_frames_import_as_one_sequence_with_sequence_interpretation() { +fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() { let (mut h, _) = harness(); let dir = frames("drop", 4); h.state_mut().session.importer = Some(Arc::new(Stills)); - for k in 1..=4 { + for k in 1..=2 { h.input_mut().dropped_files.push(Arc::new(Dropped(dir.join(format!("frame_{k:04}.png"))))); } h.run_steps(2); - step_until(&mut h, |h| !footage_items(h).is_empty()); + step_until(&mut h, |h| footage_items(h).len() == 2); + assert_eq!(footage_items(&h), [("frame_0001.png".to_string(), 0), ("frame_0002.png".to_string(), 0)]); + h.input_mut().dropped_files.push(Arc::new(Dropped(dir.clone()))); h.run_steps(2); - assert_eq!(footage_items(&h), [("frame_[0001-0004].png".to_string(), 4)]); + step_until(&mut h, |h| footage_items(h).len() == 3); + h.run_steps(2); + assert_eq!(footage_items(&h)[2], ("frame_[0001-0004].png".to_string(), 4)); assert!(h.state().dialog.is_none(), "drops don't ask"); // Interpret Footage on it (the import selected it). let ctx = h.ctx.clone(); diff --git a/docs/agents.md b/docs/agents.md index a0f4e7d0b..62fcd2169 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -251,9 +251,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole - numbered run as one item (several picked frames: that range; a folder: each run in it; - `"sequence": false`: one still; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the + file's whole numbered run as one item (several picked frames: that range; a folder, even without + `sequence`: each run in it; without `sequence` picked files import as stills, as in After + Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new diff --git a/docs/footage.md b/docs/footage.md index 784a75ad3..d25dcc342 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -26,7 +26,7 @@ dialog shows the run it found (and any missing frames) with these options: | Option | What it does | |---|---| -| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off: just the picked file imports, as a still. | +| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off (the default, as in After Effects): the picked files import as stills. | | **Force alphabetical order** | Imports every image of that type in the folder in name order, whatever the numbering, and plays the files one after another (gaps in the numbering don't count). Use it for files that aren't numbered consistently. | | **Frame rate (fps)** | The sequence's frame rate. The default comes from Settings ▸ Import ▸ Sequence Footage (30 fps). | @@ -34,10 +34,14 @@ Other ways in: - **Pick part of a run** (Shift-click the first and the last frame in the file dialog) to import just that range as one sequence. -- **Pick or drop every frame** of the run: it arrives as one sequence, not one item per frame. +- **Pick every frame** of the run with the Sequence option on: it arrives as one sequence, not + one item per frame. - **Drop a folder**: its media files import, and each numbered run in it becomes a sequence. -- **In the browser** (the web app) there are no folders to look in, so pick or drop all the - frames of the sequence at once. + Alt-drag the folder to import its files one by one. +- **Dropped files** import as stills, one item each, as in After Effects; drop the folder (or use + File ▸ Import ▸ File… with the Sequence option) for a sequence. +- **In the browser** (the web app) there are no folders to look in, so pick all the frames of + the sequence at once with the Sequence option on. Stills of any format EffectCraft reads can form a sequence: PNG, JPEG, TIFF, OpenEXR, BMP and WebP. Layered and vector documents (PSD, SVG, PDF) import one by one. @@ -48,8 +52,9 @@ Effects does, so every other frame stays at its number. Render or copy the missi folder and choose File ▸ Reload Footage to fill the gaps. Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`. -`sequence` is on by default; `"sequence": false` imports the file as a still, and -`"alphabetical": true` is Force Alphabetical Order. +Without `sequence`, picked files import as stills and a folder's numbered runs as sequences; +`"sequence": true` is the Sequence option (one file brings its whole run), `"sequence": false` +imports a folder's files one by one, and `"alphabetical": true` is Force Alphabetical Order. ## Interpret Footage From 3ba43b20c7c7d7a06ae2094f32993707e3ce21cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:29:15 +0000 Subject: [PATCH 21/28] =?UTF-8?q?Effect=20Controls:=20file=20parameters=20?= =?UTF-8?q?get=20Choose=E2=80=A6,=20Apply=20Color=20LUT=20asks=20for=20its?= =?UTF-8?q?=20LUT=20on=20apply,=20and=20text=20parameters=20get=20a=20fiel?= =?UTF-8?q?d=20(#514,=20#473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause: Apply Color LUT's only parameter, `lut`, was ParamUi::Hidden (set only through commands), so the effect could not be used from the UI. Effect Controls also had no widget for any ParamUi::Text parameter (only EXtractoR's channels had one), so Cryptomatte's Selection, the OCIO effects' config file / file / config names and Colorama's Output Cycle could not be set either. Fix: a new ParamUi::File { filters } for paths. Effect Controls shows the file's name (None when empty) and a Choose... button that opens the host's file dialog with those extensions and sets the path (prop.set, so agents use the same command). As in After Effects, applying an effect with an empty file parameter from the UI (Effect menu, Last Effect, drops, Effects & Presets) opens the file dialog straight away; cancelling keeps the effect without a file. Apply Color LUT's LUT (.cube) and the OCIO Config File (.ocio) and File (.cube/.3dl/.csp/.cc/.ccc/.cdl/.spi1d/.spi3d/.spimtx) use it. Text parameters get a single-line field that sets the value when it loses the focus (Esc keeps the old text). Saved projects are upgraded on open: a Hidden or Text parameter whose spec is now a File parameter takes the File UI and keeps its path (effects::migrate). docs/effects.md regenerated. This covers #473's first part (the Selection field); picking mattes in the viewer, the layer popup from the file's metadata, the GPU path and quoted commas are separate. Tests: effects hidden_and_text_paths_become_file_parameters (migration); ui file_and_text_parameters_have_widgets (the LUT is asked for on apply and inverts red to cyan, the Choose... button picks another LUT, typing into Cryptomatte's Selection sets it). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit 403863f703999d19d3a83d9976f76bdc7577e682) --- crates/effects/src/lib.rs | 4 + crates/effects/src/migrate.rs | 20 +++++ crates/effects/src/ocio.rs | 6 +- crates/effects/src/utility.rs | 5 +- crates/project/src/props.rs | 5 ++ crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/panels/effect_controls.rs | 93 ++++++++++++++++++++ crates/ui-egui/src/panels/effects_presets.rs | 3 +- crates/ui-egui/tests/ui_effect_controls.rs | 82 +++++++++++++++++ docs/effects.md | 2 +- 10 files changed, 215 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 4bce06367..45e2be1ce 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -430,6 +430,10 @@ pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamU pub(crate) fn slider(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi { ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals } } +/// A file parameter offering files with these extensions (Effect Controls' Choose…). +pub(crate) fn file_ui(exts: &[&str]) -> ParamUi { + ParamUi::File { filters: exts.iter().map(|s| s.to_string()).collect() } +} pub(crate) fn popup(opts: &[&str]) -> ParamUi { ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() } } diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index c161677be..0c0dad9d1 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -236,6 +236,9 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) { } // A parameter that became internal (superseded by a new one) is hidden. (_, ParamUi::Hidden) => pr.ui = ParamUi::Hidden, + // A path typed as text, or set only by commands, that got a Choose… button (Apply Color + // LUT's LUT, the OCIO files, #514). + (ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(), // Same kind of value: take the spec's hints (units, ranges). (a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(), _ => {} @@ -494,6 +497,23 @@ mod tests { assert_eq!(pr.ui, popup.ui); } + /// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were + /// plain text; old projects get the Choose… file parameter, keeping the path. + #[test] + fn hidden_and_text_paths_become_file_parameters() { + for (fx, pid, old) in [("ec.utility.applylut", "lut", ParamUi::Hidden), ("ec.color.ociofile", "file", ParamUi::Text)] { + let mut next = 1; + let (spec, mut g) = instance(fx, &mut next); + let pr = g.get_mut(pid).unwrap(); + pr.ui = old; + pr.value = Value::Str("/luts/look.cube".into()); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]), "{fx}"); + let pr = g.get(pid).unwrap(); + assert!(matches!(&pr.ui, ParamUi::File { filters } if filters.iter().any(|f| f == "cube")), "{fx}: {:?}", pr.ui); + assert_eq!(pr.value, Value::Str("/luts/look.cube".into())); + } + } + #[test] fn upgrade_moves_params_into_twirl_down_groups() { let spec = find(crate::warp_stab::ID).unwrap(); diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index c8fcf848e..74816b110 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -1427,7 +1427,7 @@ pub fn specs() -> Vec { cc, vec![ p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), - p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), space_param("source", "Source", lin709), p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), space_param("destination", "Destination", srgb), @@ -1442,7 +1442,7 @@ pub fn specs() -> Vec { cc, vec![ p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), - p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), space_param("source", "Input Color Space", lin709), p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::>())), @@ -1459,7 +1459,7 @@ pub fn specs() -> Vec { "OCIO File Transform", cc, vec![ - p("file", "File", Value::Str(String::new()), ParamUi::Text), + p("file", "File", Value::Str(String::new()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])), p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text), p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])), direction(), diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 46780aec5..1d3b3ee4a 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -385,8 +385,9 @@ pub fn specs() -> Vec { ], cineon, ), - // `lut`: a path to a `.cube` file, or the `.cube` text itself (set via commands/automation). - spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), ParamUi::Hidden)], apply_lut), + // `lut`: a path to a `.cube` file (Effect Controls' Choose…, asked for when the effect is + // applied, as in After Effects), or the `.cube` text itself (commands/automation). + spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), crate::file_ui(&["cube"]))], apply_lut), ] } diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index cdacb3f1f..f1d5149d3 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -37,6 +37,11 @@ pub enum ParamUi { Mask, Path, Text, + /// A file path (a string value) chosen with a "Choose…" button, offering files with these + /// extensions: Apply Color LUT's LUT, an OCIO config. + File { + filters: Vec, + }, Gradient, /// Not shown in Effect Controls / the timeline (internal data). Hidden, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cfbd6e52c..6e7ec9fc5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -640,8 +640,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V // up Effect Controls on that layer, as in After Effects. if matches!(id, "effect.apply" | "effect.applyLast") { let r = run_engine(app, ctx, id, params.clone()); - if r.is_ok() { + if let Ok(applied) = &r { crate::panels::effect_controls::reveal_applied(app, ¶ms); + crate::panels::effect_controls::choose_files_after_apply(app, applied); } return r; } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index adab02474..6612187e5 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -79,6 +79,46 @@ pub fn reveal_applied(app: &mut EffectcraftApp, params: &serde_json::Value) { app.raise_panel(crate::dock::PanelKind::EffectControls); } +/// After Effects asks for an effect's file as it applies it (Apply Color LUT opens the file +/// dialog for its LUT). For the instances `effect.apply` made (`applied`, its result), open the +/// file dialog for their first empty file parameter and set the file on each; cancelling +/// leaves the effect without one. Does nothing without a file dialog (tests, agents). +pub fn choose_files_after_apply(app: &mut EffectcraftApp, applied: &serde_json::Value) { + let Some(pick) = app.hooks.pick_files.as_ref() else { return }; + let Some(comp) = app.session.active_comp() else { return }; + let uids: Vec = applied.get("effects").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).collect()).unwrap_or_default(); + let mut targets = vec![]; + let mut filters: Vec = vec![]; + for uid in uids { + for l in &comp.layers { + let Some(g) = l.props.find_group(uid) else { continue }; + let mut found = None; + g.walk("", &mut |_, pr| { + if found.is_none() + && let ParamUi::File { filters } = &pr.ui + && matches!(&pr.value, Value::Str(s) if s.trim().is_empty()) + { + found = Some((pr.uid, filters.clone())); + } + }); + if let Some((prop, f)) = found { + targets.push((l.id.0, prop)); + filters = f; + } + } + } + if targets.is_empty() { + return; + } + let exts: Vec<&str> = filters.iter().map(String::as_str).collect(); + let Some(path) = pick(&exts).into_iter().next() else { return }; + for (layer, prop) in targets { + if let Err(e) = app.session.execute("prop.set", json!({"layer": layer, "prop": prop, "value": path})) { + app.ui.status = e.to_string(); + } + } +} + /// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate /// drags are separate undo steps while one drag is one step. pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { @@ -417,6 +457,59 @@ fn prop_row( let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); super::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); } + // A file parameter (Apply Color LUT's LUT, an OCIO config): its file name and Choose…, + // as in After Effects. + Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => { + let bw = 70.0; + let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0)); + if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::new(("ec-file", uid))).clicked() { + let filters: Vec<&str> = match &prop.ui { + ParamUi::File { filters } => filters.iter().map(String::as_str).collect(), + _ => vec![], + }; + match app.hooks.pick_files.as_ref() { + Some(pick) => { + if let Some(path) = pick(&filters).into_iter().next() { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path}))); + } + } + None => app.ui.status = "no file dialog available: set the path with prop.set".into(), + } + } + app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name); + let shown = if s.trim().is_empty() { + crate::i18n::tr("None").to_string() + } else if s.contains('\n') { + prop.name.clone() + } else { + effectcraft_engine::sequence::file_name(s) + }; + let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); + let np = p.with_clip_rect(nr.intersect(p.clip_rect())); + np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text }); + let hover = ui.interact(nr, egui::Id::new(("ec-file-name", uid)), Sense::hover()); + if !s.trim().is_empty() && !s.contains('\n') { + hover.on_hover_text(s.as_str()); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown); + } + // A text parameter (Cryptomatte's Selection, Colorama's Output Cycle, config names): + // typed in a field, set when it loses the focus (Esc keeps the old text). + Value::Str(s) if matches!(prop.ui, ParamUi::Text) => { + let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); + let bid = egui::Id::new(("ec-text", uid)); + let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone()); + let resp = widgets::text_field(ui, fr, &mut buf, "", &t); + app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); + if resp.lost_focus() { + if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf}))); + } + ui.data_mut(|d| d.remove::(bid)); + } else if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(bid, buf)); + } + } Value::Gradient(g) => { let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); let n = 48; diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 7b68ba7f2..e60354b15 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -422,11 +422,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; match app.session.execute(cmd, params) { Err(e) => app.ui.status = e.to_string(), - Ok(_) => { + Ok(applied) => { if let Apply::Effect(id) = &apply { push_recent(&mut app.ui.effects_recent, id); app.show_panel(crate::dock::PanelKind::EffectControls); app.ui.focused = crate::dock::PanelKind::EffectsPresets; + crate::panels::effect_controls::choose_files_after_apply(app, &applied); } } } diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index f6137f5f7..551f8ad49 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -574,3 +574,85 @@ fn custom_effect_editors_activate_their_owner() { click(&mut h, graph); assert!(h.state().session.state.selected_props.contains(&(LayerId(x.small), x.curves))); } + +/// #514: Apply Color LUT asks for its LUT as it is applied (as in After Effects), Effect +/// Controls shows the file with a Choose… button that picks another, and the LUT changes the +/// pixels. #473: a text parameter (Cryptomatte's Selection) has a field that sets it. +#[test] +fn file_and_text_parameters_have_widgets() { + let dir = std::env::temp_dir().join(format!("ec-ui-lut-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A 2×2×2 LUT that inverts, and one that does nothing. + let cube = |inv: bool| { + let mut t = String::from("LUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + let f = |c: i32| if inv { 1 - c } else { c }; + t += &format!("{} {} {}\n", f(r), f(g), f(b)); + } + } + } + t + }; + let (invert, identity) = (dir.join("invert.cube"), dir.join("identity.cube")); + std::fs::write(&invert, cube(true)).unwrap(); + std::fs::write(&identity, cube(false)).unwrap(); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "LUT", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#ff0000", "width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(l)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + let picked = std::sync::Arc::new(std::sync::Mutex::new(invert.to_string_lossy().to_string())); + let pick = picked.clone(); + app.hooks.pick_files = Some(Box::new(move |exts: &[&str]| { + assert_eq!(exts, ["cube"]); + vec![pick.lock().unwrap().clone()] + })); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let ctx = h.ctx.clone(); + let r = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.apply", json!({"effect": "Apply Color LUT"})).unwrap(); + let fx = r["effects"][0].as_u64().unwrap(); + let lut = |h: &Harness<'_, EffectcraftApp>| { + let g = layer(&h.state().session, l).effects().and_then(|f| f.groups().find(|g| g.uid == fx).cloned()).unwrap(); + let p = g.get("lut").unwrap().clone(); + ( + p.uid, + match p.value { + KV::Str(s) => s, + _ => String::new(), + }, + ) + }; + assert_eq!(lut(&h).1, invert.to_string_lossy(), "the LUT was asked for on apply"); + let px = |h: &Harness<'_, EffectcraftApp>| { + let s = &h.state().session; + s.render(s.active_comp_id().unwrap(), effectcraft_engine::time::Tick::ZERO, Default::default()).get(10, 10) + }; + assert!(px(&h)[0] < 0.05 && px(&h)[1] > 0.95, "red inverted to cyan: {:?}", px(&h)); + settle(&mut h); + let uid = lut(&h).0; + assert!(h.state().auto.find(&format!("effectControls.prop.{uid}.value")).is_some_and(|e| e.label == "invert.cube")); + *picked.lock().unwrap() = identity.to_string_lossy().to_string(); + let at = rect_of(&h, &format!("effectControls.prop.{uid}.choose")).center(); + click(&mut h, at); + assert_eq!(lut(&h).1, identity.to_string_lossy()); + assert!(px(&h)[0] > 0.95, "{:?}", px(&h)); + + // A text parameter: type into its field; it is set when the field loses the focus. + let crypto = h.state_mut().session.execute("effect.apply", json!({"layers": [l], "effect": "ec.3d.cryptomatte"})).unwrap()["effects"][0].as_u64().unwrap(); + settle(&mut h); + let sel = + layer(&h.state().session, l).effects().and_then(|f| f.groups().find(|g| g.uid == crypto).and_then(|g| g.get("selection")).map(|p| p.uid)).unwrap(); + let at = rect_of(&h, &format!("effectControls.prop.{sel}.value")).center(); + click(&mut h, at); + h.event(egui::Event::Text("left*".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let v = layer(&h.state().session, l).props.find(sel).unwrap().value.clone(); + assert_eq!(v, KV::Str("left*".into())); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/docs/effects.md b/docs/effects.md index 4073251ae..e498cf1ae 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -412,7 +412,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Apply Color LUT | `ec.utility.applylut` | 0 | GPU | 32 | Implemented | +| Apply Color LUT | `ec.utility.applylut` | 1 | GPU | 32 | Implemented | | CC Overbrights | `ec.utility.ccoverbrights` | 2 | GPU | 32 | Implemented | | Cineon Converter | `ec.utility.cineon` | 7 | GPU | 32 | Implemented | | Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | GPU | 32 | Implemented | From 1bea11c9f1c83204382cb5b8aac69e9e49f14967 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:29:15 +0000 Subject: [PATCH 22/28] Roto Brush: Cmd/Ctrl-drag resizes the brush, and the tool options show its Diameter (#509) Cause: the Roto Brush and Refine Edge diameters live in the editor state and the roto.options command sets them, but the UI never did: the Layer panel only painted strokes and the bottom bar only set the view mode, so the brush size could not be changed. Fix, as in After Effects: Cmd-drag (Ctrl-drag on Windows and Linux) in the Layer panel resizes the active brush (Roto Brush or Refine Edge) without painting. The circle stays at the press point, its edge follows the pointer, and the size is shown while dragging. The tools bar shows a Diameter field (header.roto.diameter) while a Roto Brush tool is active. Both go through roto.options, and the next stroke uses the new radius. Diameter is in all four interface catalogs. Test: roto_brush_ctrl_drag_resizes_the_brush (a 20 px Cmd/Ctrl-drag makes a 40 px brush and paints nothing, the next stroke's radius is half of it, and the Diameter field is registered). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit b4183c28eea34ba097d7a16153b84fcf7aeea045) --- crates/ui-egui/src/header.rs | 23 ++++++++++++++++ crates/ui-egui/src/i18n/ui.rs | 4 +++ crates/ui-egui/src/panels/roto_tool.rs | 26 ++++++++++++++++++ crates/ui-egui/tests/ui_viewer.rs | 37 ++++++++++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index f013e5b79..40472f52b 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -164,6 +164,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.ui.tool.puppet_kind().is_some() { x = puppet_options(app, ui, &p, x, cy); } + if app.ui.tool.roto_kind(false).is_some() { + x = roto_options(app, ui, &p, x, cy); + } let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; @@ -481,6 +484,26 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: p.rect_stroke(r, 2.0, Stroke::new(1.0, if hovered { t.text } else { t.field_border }), StrokeKind::Inside); } +/// Roto Brush / Refine Edge tool options: the brush Diameter (⌘/Ctrl-drag in the Layer panel +/// sets it too, as in After Effects). Returns the next x. +fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; + let refine = app.ui.tool.roto_kind(false).is_some_and(|k| k.starts_with("refine")); + let o = &app.session.state.roto; + let (key, v) = if refine { ("refineDiameter", o.refine_diameter) } else { ("diameter", o.diameter) }; + let label = tr("Diameter:"); + let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); + x = lr.max.x + 6.0; + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t); + app.auto.add("header.roto.diameter", r, label); + if let Some(nv) = nv + && let Err(e) = app.session.execute("roto.options", json!({key: nv})) + { + app.ui.status = e.to_string(); + } + r.max.x + 12.0 +} + /// Puppet tool options: Mesh: Show, Expansion, Density (for new meshes and the selected /// layer's meshes). Returns the next x. fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index d1e81f1e4..ce7c2a99c 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -286,6 +286,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Maximize Panel", "パネルを最大化"), ("Media Replacement", "メディアの置き換え"), ("Mesh:", "メッシュ:"), + ("Diameter:", "直径:"), ("Method Used: {} Average Error: {} pixels", "使用した手法:{} 平均誤差:{}ピクセル"), ("Models folder: {}", "モデルフォルダー:{}"), ("Modifiers:", "修飾キー:"), @@ -721,6 +722,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒体替换"), ("Mesh:", "网格:"), + ("Diameter:", "直径:"), ("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均误差:{} 像素"), ("Models folder: {}", "模型文件夹:{}"), ("Modifiers:", "修饰键:"), @@ -1244,6 +1246,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒體替換"), ("Mesh:", "格點:"), + ("Diameter:", "直徑:"), ("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均誤差:{} 像素"), ("Models folder: {}", "模型資料夾:{}"), ("Modifiers:", "修飾鍵:"), @@ -1757,6 +1760,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Maximize Panel", "Розгорнути панель"), ("Media Replacement", "Заміна матеріалу"), ("Mesh:", "Сітка:"), + ("Diameter:", "Діаметр:"), ("Method Used: {} Average Error: {} pixels", "Використаний метод: {} Середня похибка: {} пікселів"), ("Models folder: {}", "Тека моделей: {}"), ("Modifiers:", "Модифікатори:"), diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index f90909819..fbd7119c8 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -168,6 +168,32 @@ pub fn interact( let sign = if kind == "bg" || kind == "refineErase" { "−" } else { "+" }; painter.text(hp, Align2::CENTER_CENTER, sign, Tokens::semibold(12.0), col); } + // ⌘/Ctrl-drag resizes the brush (After Effects): the circle stays at the press point and its + // edge follows the pointer. No stroke is painted. + let rid = egui::Id::new("layer-panel-roto-resize"); + if resp.drag_started() + && mods.command + && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + ctx.data_mut(|d| d.insert_temp(rid, pos)); + } + if let Some(origin) = ctx.data(|d| d.get_temp::(rid)) { + if let Some(pos) = resp.interact_pointer_pos() + && zoom > 0.0 + { + let dia = (2.0 * f64::from(origin.distance(pos)) / zoom).clamp(1.0, 5000.0).round(); + let key = if refine { "refineDiameter" } else { "diameter" }; + if let Err(e) = app.session.execute("roto.options", json!({key: dia})) { + app.ui.status = e.to_string(); + } + painter.circle_stroke(origin, (dia * 0.5 * zoom) as f32, Stroke::new(1.5, col)); + painter.text(origin + vec2(0.0, -14.0), Align2::CENTER_BOTTOM, format!("{dia:.0} px"), Tokens::ui(11.0), col); + } + if resp.drag_stopped() || !resp.dragged() { + ctx.data_mut(|d| d.remove::(rid)); + } + return true; + } let did = egui::Id::new("layer-panel-roto-drag"); if resp.drag_started() && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index fd12d5553..bada77a79 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1837,3 +1837,40 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res); assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3"); } + +/// #509: ⌘/Ctrl-drag in the Layer panel resizes the Roto Brush (as in After Effects) without +/// painting, the next stroke uses the new size, and the tool options show the Diameter. +#[test] +fn roto_brush_ctrl_drag_resizes_the_brush() { + let mut h = harness(); + let id = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap(); + h.state_mut().ui.layer_panel = Some(id); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Layer); + h.state_mut().ui.tool = Tool::RotoBrush; + h.run_steps(3); + assert!(h.state().auto.find("header.roto.diameter").is_some(), "the Diameter field"); + let canvas = rect(&h, "layerPanel.canvas"); + let zoom = canvas.width() / 80.0; + let c = canvas.center(); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::COMMAND)); + drag(&mut h, c, c + vec2(20.0 * zoom, 0.0)); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); + let dia = h.state().session.state.roto.diameter; + assert!((dia - 40.0).abs() <= 1.0, "a 20 px drag makes a 40 px brush: {dia}"); + let strokes = |h: &Harness<'_, EffectcraftApp>| { + let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + let roto = effectcraft_engine::effects::roto::ID; + let g = l + .effects() + .and_then(|fx| fx.groups().find(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == roto)).cloned()); + g.map(|g| effectcraft_engine::effects::roto::data(&effectcraft_engine::effects::flatten_params(&g, &mut |p| p.value.clone())).strokes.clone()) + .unwrap_or_default() + }; + assert!(strokes(&h).is_empty(), "resizing paints nothing"); + drag(&mut h, c - vec2(10.0 * zoom, 0.0), c + vec2(10.0 * zoom, 0.0)); + let s = strokes(&h); + assert_eq!(s.len(), 1); + assert!((s[0].radius - dia / 2.0).abs() < 1e-6, "{} vs {dia}", s[0].radius); +} From f440adbb5a4931709a40609b9e209b46d6787c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:53:56 +0000 Subject: [PATCH 23/28] Dock: dragging a divider past a panel's minimum no longer crashes the app (#306) A split's first side was sized with `f32::clamp(20.min(avail), (avail - 20).max(0))`. `f32::clamp` panics when its lower bound exceeds its upper one, which happened for any split with 0 < avail < 40 pt. Dragging the viewer/Timeline divider up takes the area above it down to 40 pt, so the splits nested there (the right column's vertical split and others) got about 36 pt and the layout panicked; no guard covers egui's update, so the app closed with no dialog. The split size now goes through `split_first` / `clamp_ordered`, which never panic: when the room is too small for both minimums the lower bound wins, and non-finite sizes fall back to it. The gutter drag uses the same clamp. Tests: a dock unit test lays out every workspace in rects too short and too narrow for its minimums (it panicked before) and checks `split_first` on hostile sizes; a kittest drags the divider above the Timeline 2000 pt up and 3000 pt down. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/dock.rs | 62 +++++++++++++++++++++++++++++---- crates/ui-egui/tests/ui_tabs.rs | 27 ++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 65bca8803..b586e8c95 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -878,12 +878,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa let g = t.gap; let total = if *vertical { rect.height() } else { rect.width() }; let avail = (total - g).max(0.0); - let first = match *size { - SplitSize::Ratio(r) => avail * r, - SplitSize::FixedA(px) => px.min(avail - 20.0), - SplitSize::FixedB(px) => avail - px.min(avail - 20.0), - } - .clamp(20.0_f32.min(avail), (avail - 20.0).max(0.0)); + let first = split_first(*size, avail); let (ra, gutter_rect, rb) = if *vertical { ( Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)), @@ -898,7 +893,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa ) }; if let Some(d) = gutter(ui, gutter_rect, *vertical, path, t, reg) { - let nf = (first + d).clamp(40.0, (avail - 40.0).max(40.0)); + let nf = clamp_ordered(first + d, 40.0, avail - 40.0); *size = match *size { SplitSize::Ratio(_) => SplitSize::Ratio(nf / avail.max(1.0)), SplitSize::FixedA(_) => SplitSize::FixedA(nf), @@ -965,6 +960,27 @@ fn stack_bodies(entries: &[StackEntry], avail: f32) -> Vec { .collect() } +/// `v` held to `lo..=hi`, never panicking: when the room is too small for both minimums +/// (`hi < lo`), `lo` wins; a non-finite `v` gives `lo`. +fn clamp_ordered(v: f32, lo: f32, hi: f32) -> f32 { + let lo = if lo.is_finite() { lo } else { 0.0 }; + let hi = if hi.is_finite() { hi.max(lo) } else { lo }; + if v.is_finite() { v.max(lo).min(hi) } else { lo } +} + +/// The size of a split's first side in `avail` points (both sides keep 20 pt when there is room). +/// `f32::clamp` panics when its bounds cross, which a split shorter than 40 pt did (#306). +fn split_first(size: SplitSize, avail: f32) -> f32 { + let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 }; + let first = match size { + SplitSize::Ratio(r) => avail * r, + SplitSize::FixedA(px) => px.min(avail - 20.0), + SplitSize::FixedB(px) => avail - px.min(avail - 20.0), + }; + let lo = 20.0_f32.min(avail); + clamp_ordered(first, lo, avail - 20.0).min(avail) +} + /// Drag the gap between the open stacked panels `above` and `below` (laid out at `bodies`) by /// `d` points: one grows as much as the other shrinks, neither below [`STACK_MIN`]. The new sizes /// become the panels' heights, except that the last panel without one keeps sharing the rest @@ -1328,6 +1344,38 @@ mod tests { } } + #[test] + fn short_splits_lay_out_without_panicking() { + // #306: a split 0 < avail < 40 pt tall made `f32::clamp` panic (its bounds crossed). + for avail in [0.0, 5.0, 19.0, 20.0, 30.0, 39.0, 40.0, 41.0, -10.0, f32::NAN, f32::INFINITY] { + for size in [SplitSize::Ratio(0.5), SplitSize::Ratio(f32::NAN), SplitSize::FixedA(300.0), SplitSize::FixedB(300.0), SplitSize::FixedA(-5.0)] { + let f = split_first(size, avail); + assert!(f.is_finite() && f >= 0.0, "{size:?} {avail}: {f}"); + if avail.is_finite() && avail > 0.0 { + assert!(f <= avail, "{size:?} {avail}: {f}"); + } + } + } + assert_eq!(split_first(SplitSize::FixedA(300.0), 30.0), 20.0); + assert_eq!(split_first(SplitSize::Ratio(0.5), 200.0), 100.0); + assert_eq!(clamp_ordered(5.0, 40.0, -4.0), 40.0); + // Every workspace, laid out in rects too short and too narrow for its minimums. + let ctx = egui::Context::default(); + let t = Tokens::for_kind(crate::theme::ThemeKind::Dark); + for w in WORKSPACES { + for (wd, ht) in [(30.0, 30.0), (800.0, 36.0), (800.0, 45.0), (36.0, 600.0), (1.0, 1.0), (0.0, 0.0)] { + let mut node = workspace(w); + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { + let mut out = vec![]; + let mut reg = crate::automation::Registry::default(); + layout(ui, &mut node, Rect::from_min_size(pos2(0.0, 0.0), vec2(wd, ht)), &t, "", &mut out, &mut reg); + assert!(!out.is_empty(), "{w}"); + }); + frame.textures_delta.clear(); + } + } + } + #[test] fn drop_zone_math() { let r = Rect::from_min_size(pos2(100.0, 100.0), vec2(400.0, 300.0)); diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index d3a71ec32..207e5cf48 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -298,3 +298,30 @@ fn project_empty_area_double_click_and_dropped_files_import() { h.run_steps(2); assert_eq!(imports(&h), 2, "the dropped file was imported"); } + +/// Issue #306: dragging the viewer/Timeline divider far up made a nested split shorter than its +/// two 20 pt minimums, and `f32::clamp` panicked with crossed bounds, closing the app. +#[test] +fn dragging_the_viewer_timeline_divider_far_up_does_not_crash() { + let (mut h, _, _) = harness(); + // The horizontal gutter right above the Timeline. + let tl = rect(&h, "panel.Timeline"); + let gutter = |h: &Harness<'_, EffectcraftApp>| { + h.state() + .auto + .previous + .iter() + .filter(|e| e.id.starts_with("dock.gutter.") && e.rect[2] > e.rect[3]) + .map(|e| (e.id.clone(), egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))) + .filter(|(_, g)| g.x_range().contains(tl.center().x) && g.max.y <= tl.min.y + 1.0) + .min_by(|a, b| (tl.min.y - a.1.max.y).total_cmp(&(tl.min.y - b.1.max.y))) + .expect("the gutter above the Timeline") + }; + let (id, g) = gutter(&h); + drag(&mut h, g.center(), g.center() - egui::vec2(0.0, 2000.0)); + let g2 = rect(&h, &id); + assert!(g2.center().y < g.center().y - 100.0, "the divider moved up: {g:?} → {g2:?}"); + // And back down past the bottom. + drag(&mut h, g2.center(), g2.center() + egui::vec2(0.0, 3000.0)); + h.run_steps(2); +} From 7b38d4d74e82881a71c2fe4c352ee3f7bdd476b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:54:17 +0000 Subject: [PATCH 24/28] Dock: a click anywhere on a stacked panel's header bar expands or collapses it (#301) In the right-hand panel stack only the tab (the name plus its padding) answered clicks, so the rest of the header bar did nothing, a small target for short names like Audio. As in After Effects, the rest of the strip now toggles the panel too and focuses it. A stack group holds one panel, so the bar is unambiguous; the panel menu keeps its own click. The bar registers the automation id `panel.header.`. Test: a kittest clicks near the right end of Preview's header bar (collapses), its middle (expands) and its panel menu button (stays open). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/dock.rs | 15 +++++++++++++++ crates/ui-egui/tests/ui_tabs.rs | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index b586e8c95..d0daa86d9 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1177,6 +1177,21 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra } x += w + 8.0; } + // A stacked panel's whole header bar expands/collapses it, not just its name (as in After + // Effects). A stack group holds one panel, so the rest of the strip is unambiguous. + if g.stacked.is_some() + && let Some(e) = entries.first() + { + let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max); + if rest.width() > 0.0 { + let resp = ui.interact(rest, egui::Id::new(("tab-rest", g.path.clone())), Sense::click()); + reg.add(&format!("panel.header.{}", e.panel.id()), rest, &e.label); + if resp.clicked() { + out.actions.push(DockAction::ToggleStacked(e.panel)); + out.actions.push(DockAction::Focus(e.panel)); + } + } + } } // clicking anywhere in the panel focuses it if let Some(p) = active_panel diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index 207e5cf48..8b52a72a6 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -325,3 +325,23 @@ fn dragging_the_viewer_timeline_divider_far_up_does_not_crash() { drag(&mut h, g2.center(), g2.center() + egui::vec2(0.0, 3000.0)); h.run_steps(2); } + +/// Issue #301: clicking anywhere on a stacked panel's header bar expands/collapses it, not only +/// its name; the panel menu button keeps its own click. +#[test] +fn a_stacked_panels_whole_header_bar_toggles_it() { + let (mut h, _, _) = harness(); + let open = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.is_visible(PanelKind::Preview); + assert!(open(&h)); + let bar = rect(&h, "panel.header.Preview"); + let tab = rect(&h, "panel.tab.Preview"); + assert!(bar.min.x >= tab.max.x - 1.0 && bar.width() > 40.0, "{bar:?} {tab:?}"); + click_n(&mut h, pos2(bar.max.x - 6.0, bar.center().y), 1); + assert!(!open(&h), "a click at the bar's right end collapses Preview"); + let bar = rect(&h, "panel.header.Preview"); + click_n(&mut h, bar.center(), 1); + assert!(open(&h), "and expands it again"); + let menu = rect(&h, "panel.menu.Preview"); + click_n(&mut h, menu.center(), 1); + assert!(open(&h), "the panel menu button doesn't toggle the panel"); +} From 3effa9a2ea80255270d9bbfdbfb758e0402edb15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:54:17 +0000 Subject: [PATCH 25/28] Timeline: the view follows the CTI past its edges when scrubbing and during playback (#468) The ruler scrub set the time under the pointer and never moved the visible span, so with the timeline zoomed in a scrub couldn't go past the edge, and a preview ran the CTI out of view. As in After Effects: - zoomed in, a scrub held past either end of the ruler scrolls the timeline that way, faster the further past, with the CTI riding the edge (repainting while held); - during playback, when the CTI leaves the visible span the view pages to start at it (not past the comp's end). Tests: a unit test of the paging and edge speed, and a kittest that holds a scrub past the right end (the view scrolls, the CTI stays at the edge), then past the left end, then plays from beyond the view (it pages to the CTI). (#468's Shift+F and black-layer items need more information and aren't part of this.) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/timeline.rs | 60 +++++++++++++++++++++- crates/ui-egui/tests/ui_timeline_zoom.rs | 65 ++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439be..edf990cce 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -212,6 +212,29 @@ fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 { (depth / zone).clamp(-3.0, 3.0) * 400.0 } +/// Autoscroll speed (points per second, left negative) of a ruler scrub held at `x`, past the +/// left (`x0`) or right (`x1`) end of the visible time span: faster the further past. +fn edge_scroll_speed_x(x0: f32, x1: f32, x: f32) -> f32 { + let depth = if x < x0 { + x - x0 + } else if x > x1 { + x - x1 + } else { + return 0.0; + }; + (depth / 40.0).clamp(-3.0, 3.0) * 400.0 + depth.signum() * 100.0 +} + +/// The timeline's first visible time (`start`, showing `span` seconds of a `duration` comp) +/// paged so the CTI at `cti` is in view: unchanged while it is, else the page that starts at +/// the CTI (as After Effects pages the Timeline during a preview). +fn page_to_cti(start: f64, span: f64, duration: f64, cti: f64) -> f64 { + if !(start.is_finite() && span.is_finite() && cti.is_finite()) || (cti >= start && cti <= start + span) { + return start; + } + cti.min(duration - span).max(0.0) +} + /// The text of an expression being edited when its pick whip was pressed, and the selection in /// it (characters): the pick whip's reference goes in there. type WhipInto = (String, [usize; 2]); @@ -1217,6 +1240,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let pps = app.ui.timeline.pps.unwrap_or(fit_pps); if app.ui.timeline.pps.is_none() { app.ui.timeline.start = 0.0; + } else if app.playback.playing { + // During playback the view pages to keep the CTI in sight (After Effects). + let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0); + app.ui.timeline.start = page_to_cti(app.ui.timeline.start, span, comp.duration.seconds(), app.session.time().seconds()); } let tm = TMap { x0: graph_x0 + 6.0, start: app.ui.timeline.start, pps }; ctx.data_mut(|d| d.insert_temp(tl_map_id(), (tm.x0, tm.start, tm.pps))); @@ -1482,7 +1509,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked()) && let Some(pt) = rresp.interact_pointer_pos() { - let mut secs = tm.t(pt.x).max(0.0); + let mut x = pt.x; + // Zoomed in, a scrub held past either end of the ruler scrolls the timeline that way + // (faster the further), with the CTI at the edge (After Effects). + let mut tm = tm; + if rresp.dragged() && app.ui.timeline.pps.is_some() { + let speed = edge_scroll_speed_x(tm.x0, graph_x1, x); + if speed != 0.0 { + let dt = f64::from(ui.input(|i| i.stable_dt).clamp(0.0, 0.1)); + let span = f64::from(graph_x1 - tm.x0) / pps; + let cur = app.ui.timeline.start; + let hi = (comp.duration.seconds() - span).max(0.0).max(cur); + app.ui.timeline.start = (cur + f64::from(speed) * dt / pps).clamp(0.0, hi); + tm.start = app.ui.timeline.start; + ui.ctx().request_repaint(); + } + x = x.clamp(tm.x0, graph_x1); + } + let mut secs = tm.t(x).max(0.0); // Shift-drag: snap to keyframes, in/out points, markers and the work area (8 px). if ui.input(|i| i.modifiers.shift) { let cands = snap_candidates(&comp, &build_rows(app, &comp)); @@ -3619,6 +3663,20 @@ fn value_editor( mod tests { use super::*; + /// #468: playback pages the view to the CTI; a scrub past either end scrolls that way. + #[test] + fn the_view_pages_to_the_cti() { + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 12.0), 10.0, "in view: unchanged"); + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 16.0), 16.0, "past the right end: the page from the CTI"); + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 3.0), 3.0, "before the start"); + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 58.0), 55.0, "not past the comp's end"); + assert_eq!(page_to_cti(10.0, 80.0, 60.0, 95.0), 0.0); + assert_eq!(page_to_cti(10.0, f64::NAN, 60.0, 95.0), 10.0); + assert_eq!(edge_scroll_speed_x(100.0, 500.0, 300.0), 0.0); + assert!(edge_scroll_speed_x(100.0, 500.0, 501.0) > 0.0 && edge_scroll_speed_x(100.0, 500.0, 99.0) < 0.0); + assert!(edge_scroll_speed_x(100.0, 500.0, 600.0) > edge_scroll_speed_x(100.0, 500.0, 520.0)); + } + /// A comp with two solids; "Box" has an animated Position and a Gaussian Blur. fn app() -> EffectcraftApp { let mut s = effectcraft_engine::Session::default(); diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 48b19cb8f..d2103811c 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -311,3 +311,68 @@ fn dragging_out_of_the_marker_bin_adds_a_comp_marker() { h.run_steps(2); assert_eq!(markers(&h), vec![13.0, 20.0]); } + +/// #468: zoomed in, a CTI scrub held past the right end of the ruler scrolls the timeline (the +/// CTI rides the edge), past the left end scrolls it back; during playback the view pages to +/// keep the CTI in sight. +#[test] +fn the_timeline_follows_the_cti_past_its_edges() { + let (mut h, _) = harness(); + // (Opening the comp showed all of it.) + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(2); + let ruler = rect(&h, "timeline.ruler"); + let (s0, e0) = visible(&h); + assert_eq!(s0, 0.0); + let y = ruler.max.y - 4.0; + let from = pos2(ruler.center().x, y); + let past = pos2(ruler.max.x + 60.0, y); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (past - from) * (k as f32 / 4.0))); + h.step(); + } + // Held still past the edge: it keeps scrolling (the view is read once the button is up). + h.run_steps(30); + let release = |h: &mut Harness<'_, EffectcraftApp>, at: Pos2| { + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + release(&mut h, past); + let (s1, e1) = visible(&h); + let cti = h.state().session.time().seconds(); + assert!(s1 > 0.5, "the view scrolled right: {s0}..{e0} → {s1}..{e1}"); + assert!(cti > e0 && cti >= s1 && cti <= e1 + 0.05, "the CTI rides the right edge: {cti} in {s1}..{e1}"); + // Pressed again and held past the left end: back to the left. + let left = pos2(ruler.min.x - 60.0, y); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (left - from) * (k as f32 / 4.0))); + h.step(); + } + h.run_steps(5); + release(&mut h, left); + let (s2, _) = visible(&h); + let cti = h.state().session.time().seconds(); + assert!(s2 < s1 && (cti - s2).abs() < 0.05, "the view scrolled back left: {s1} → {s2}, CTI {cti}"); + + // Playback past the visible span pages the view to the CTI. + let (s3, e3) = visible(&h); + let t = e3 + 20.0; + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(t)); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + assert!(h.state().playback.playing); + h.step(); + h.state_mut().stop(); + h.step(); + let cti = h.state().session.time().seconds(); + let (s4, e4) = visible(&h); + assert!(s4 > s3 && cti >= s4 && cti <= e4, "paged from {s3}..{e3} to {s4}..{e4} for the CTI at {cti}"); +} From a220b63bfbd816b94f5c75111e1fe38bf96444d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:54:17 +0000 Subject: [PATCH 26/28] Help: the System Compatibility Report says why there is no GPU and names the window's adapter (#596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no GPU compositor the report only said "GPU: none", though the host had recorded the reason (`Session::accel_note`: the compositor's setup error, a retired GPU preview, a headless start) and the window's wgpu adapter was known. Users with a capable GPU (#596, an RTX 4070) couldn't tell why it wasn't used. The report now carries `gpuNote` (the reason, when there is no compositor) and `windowGraphics` (the adapter, backend, device type and driver the window draws with, which the desktop app records when it sets up the GPU), and the text shows "GPU: none (CPU compositing): " and "Window graphics: …"; the issue line names the reason too. Test: the compatibility report test sets a reason and an adapter and checks the JSON and text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/app_more.rs | 16 ++++++++++++++-- crates/engine/src/lib.rs | 4 ++++ crates/engine/src/tests_m145.rs | 9 +++++++++ crates/ui-egui/src/lib.rs | 9 +++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 347802747..b6101c5a9 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -328,7 +328,10 @@ pub fn system_report(s: &Session) -> Value { let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory)); let mut issues = vec![]; if gpu.is_none() { - issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string()); + issues.push(match &s.accel_note { + Some(why) => format!("No GPU compositor ({why}): rendering uses the CPU (all features still work)."), + None => "No GPU compositor: rendering uses the CPU (all features still work).".to_string(), + }); } if mem.is_some_and(|m| m.total < 8 << 30) { issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string()); @@ -344,6 +347,8 @@ pub fn system_report(s: &Session) -> Value { "cores": crate::sysinfo::cpu_cores(), "memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})), "gpu": gpu, + "gpuNote": if gpu.is_none() { s.accel_note.clone() } else { None }, + "windowGraphics": s.window_adapter, "caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)}, "import": IMPORT_FORMATS, "export": export, @@ -369,7 +374,14 @@ pub fn report_text(r: &Value) -> String { Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))), None => t.push_str("Memory: unknown\n"), } - t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)"))); + match (r.get("gpu").and_then(Value::as_str), r.get("gpuNote").and_then(Value::as_str)) { + (Some(gpu), _) => t.push_str(&format!("GPU: {gpu}\n")), + (None, Some(why)) => t.push_str(&format!("GPU: none (CPU compositing): {why}\n")), + (None, None) => t.push_str("GPU: none (CPU compositing)\n"), + } + if let Some(w) = r.get("windowGraphics").and_then(Value::as_str) { + t.push_str(&format!("Window graphics: {w}\n")); + } let c = &r["caches"]; t.push_str(&format!( "Cache budgets: layer {}, footage {}, preview {}\n", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9ee6b89f..ac805f4cf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -368,6 +368,9 @@ pub struct Session { /// Why there is no [`Session::accel`] (`render.backend`'s `why`): the host's reason, e.g. a /// headless start without `--gpu` or the compositor's setup error. pub accel_note: Option, + /// The graphics adapter and backend the window draws with (`NVIDIA GeForce RTX 4070 + /// (Vulkan)`), as the host reports it: the System Compatibility Report shows it. + pub window_adapter: Option, /// Expression syntax checker (set by the host that links the expression engine). pub expr_check: Option std::result::Result<(), String>>, pub importer: Option>, @@ -511,6 +514,7 @@ impl Default for Session { expr: None, accel: None, accel_note: None, + window_adapter: None, expr_check: None, importer: None, exporter: None, diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index b55442eb8..2431c17cd 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -455,6 +455,15 @@ fn compatibility_report_and_logging() { } let text = crate::commands::report_text_for_tests(&r); assert!(text.contains("Operating system") && text.contains("Import:")); + // #596: without a GPU compositor the report says why, and names the window's adapter. + s.accel_note = Some("GPU compositor unavailable: no storage textures".into()); + s.window_adapter = Some("NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)".into()); + let r = s.execute("help.systemReport", json!({"quiet": true})).unwrap(); + assert_eq!(r["gpuNote"], "GPU compositor unavailable: no storage textures"); + let text = crate::commands::report_text_for_tests(&r); + assert!(text.contains("GPU: none (CPU compositing): GPU compositor unavailable: no storage textures"), "{text}"); + assert!(text.contains("Window graphics: NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)"), "{text}"); + assert!(r["issues"][0].as_str().is_some_and(|i| i.contains("no storage textures")), "{r}"); s.drain_events(); s.execute("help.systemReport", json!({})).unwrap(); assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.showReport"))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd0efc0ca..f9175b660 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -612,6 +612,15 @@ impl EffectcraftApp { return; } let Some(rs) = frame.wgpu_render_state() else { return }; + // Help ▸ System Compatibility Report names the adapter and backend the window got. + let info = rs.adapter.get_info(); + self.session.window_adapter = Some(format!( + "{} ({:?}, {:?}{})", + info.name, + info.backend, + info.device_type, + if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) } + )); // Backend validation can panic on shader compilation even when device creation // succeeded. Keep the document and the egui renderer, and use CPU compositing. let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); From 8d32d67f0e19eaa6a2c48fd33508856d8f0046a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:54:17 +0000 Subject: [PATCH 27/28] =?UTF-8?q?Color:=20Project=20Settings=20=E2=96=B8?= =?UTF-8?q?=20Working=20Gamma=202.2=20/=202.4=20with=20no=20working=20spac?= =?UTF-8?q?e,=20as=20in=20After=20Effects=20(#495)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Effects' `app.project.workingGamma` (2.2 or 2.4, ignored with a working space) sets the curve an unmanaged project uses: at 2.2 the piecewise sRGB curve, at 2.4 a pure 2.4 power curve. EffectCraft always used sRGB, so linear EXRs (Interpret As Linear Light) from a 2.4 project came out darker in the shadows (0.18 → 0.461 instead of 0.490). - `ProjectSettings::working_gamma` (default 2.2, serde default for old projects). - The colour pipe's unmanaged curve is the Working Gamma's (sRGB, or Rec. 709's pure 2.4 curve): Interpret As Linear Light encodes untagged footage with it and Blend Colors Using 1.0 Gamma linearizes with it; it is part of the pipe's cache key. The GPU path takes the same conversions. - `file.projectSettings {workingGamma: 2.2|2.4}` (other values are refused, as in AE), `app.project.workingGamma` in scripts and a Working gamma choice in Project Settings. Test: render test with linear 0.051 / 0.18 / 0.5 footage (0.290 / 0.490 / 0.749 at 2.4, 0.250 / 0.461 / 0.735 at 2.2, the values measured in AE), linear blending at 2.4, a working space ignoring the setting, and the cache key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/file.rs | 10 +++++++- crates/project/src/lib.rs | 18 ++++++++++++++ crates/render/src/color.rs | 18 +++++++++++--- crates/render/src/lib.rs | 9 +++---- crates/render/src/tests_color.rs | 40 ++++++++++++++++++++++++++++++ crates/script/src/model.rs | 1 + crates/script/src/prelude.js | 2 ++ crates/ui-egui/src/panels/forms.rs | 2 ++ 8 files changed, 91 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index f0396e5df..d3b9fec4a 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -577,6 +577,14 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { ), }; } + if let Some(g) = p.get("workingGamma") { + // After Effects takes 2.2 or 2.4 only (`app.project.workingGamma`). + proj.settings.working_gamma = match g.as_f64() { + Some(g) if (g - 2.2).abs() < 1e-6 => 2.2, + Some(g) if (g - 2.4).abs() < 1e-6 => 2.4, + _ => return Err(bad(cmd, format!("workingGamma: 2.2|2.4, not `{g}`"))), + }; + } if proj.settings.color_engine == ColorEngine::Ocio && !proj.settings.working_space.is_some_and(|w| w.is_linear()) { return Err(bad(cmd, "the OCIO built-in config's working spaces are acescg and aces2065")); } @@ -689,7 +697,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", + "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", always, project_settings ), diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 064ac2e0a..c1f110e0d 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -219,6 +219,23 @@ pub struct ProjectSettings { /// sRGB; Rec. 2100 PQ / HLG give HDR output. #[serde(default)] pub output_space: Option, + /// Working Gamma, 2.2 or 2.4 (`app.project.workingGamma`): with no working space, the curve + /// Interpret As Linear Light footage is encoded with and Blend Colors Using 1.0 Gamma + /// linearizes with: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4. Ignored with a + /// working space (as in After Effects). + #[serde(default = "gamma_22")] + pub working_gamma: f64, +} + +fn gamma_22() -> f64 { + 2.2 +} + +impl ProjectSettings { + /// Working Gamma is 2.4 (anything else reads as 2.2). + pub fn working_gamma_24(&self) -> bool { + (self.working_gamma - 2.4).abs() < 0.05 + } } impl Default for ProjectSettings { @@ -236,6 +253,7 @@ impl Default for ProjectSettings { color_engine: ColorEngine::Adobe, hdr: HdrMode::Clip, output_space: None, + working_gamma: 2.2, } } } diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index 197857225..a420256e8 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -14,7 +14,8 @@ //! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels //! are linearised just before they are transformed and blended, the comp accumulates in linear //! and is encoded back to the working space when the comp is done (so precomps hand encoded -//! pixels to their parent). Without a working space the sRGB curve is used. +//! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB +//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage. use effectcraft_color::{ColorSpace, Conversion}; use effectcraft_project::{Footage, HdrMode, ProjectSettings}; @@ -36,6 +37,8 @@ pub struct Pipe { pub out: ColorSpace, /// HDR handling for standard-dynamic-range output. pub hdr: HdrMode, + /// Unmanaged with Working Gamma 2.4: the pure 2.4 power curve instead of sRGB's. + pub gamma24: bool, } impl Pipe { @@ -49,12 +52,20 @@ impl Pipe { linear_blend: s.blend_linear && !linear, out: s.output_space.unwrap_or(ColorSpace::Srgb), hdr: s.hdr, + gamma24: s.working_space.is_none() && s.working_gamma_24(), } } - /// The space whose curve encodes working-space pixels (sRGB when unmanaged). + /// The space whose curve encodes working-space pixels (unmanaged: the Working Gamma's, sRGB + /// at 2.2 and Rec. 709's pure 2.4 power curve at 2.4). fn curve(&self) -> ColorSpace { - self.space.unwrap_or(ColorSpace::Srgb) + self.space.unwrap_or(if self.gamma24 { ColorSpace::Rec709 } else { ColorSpace::Srgb }) + } + + /// The space Interpret As Linear Light encodes footage `f` in: its colour profile, else sRGB + /// (managed; the working space converts from there) or the Working Gamma's curve (unmanaged). + pub fn linear_footage_space(&self, f: &Footage) -> ColorSpace { + f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() }) } /// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for @@ -149,6 +160,7 @@ impl Pipe { k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11)); k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64); k = k.wrapping_mul(31).wrapping_add(self.out as u64 * 4 + self.hdr as u64); + k = k.wrapping_mul(31).wrapping_add(self.gamma24 as u64); k } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 46e108661..79dcc21bd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1200,12 +1200,12 @@ impl<'a> Renderer<'a> { let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; - return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f))); + return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f)))); } // Interpretation is per pixel but not linear: it runs on the frame before resampling. let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; - if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) } + if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { @@ -2074,13 +2074,12 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { } /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) -/// pixels. -pub fn interpret_pixels(img: &Image, f: &Footage) -> Image { +/// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]). +pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image { let linear = f.linear_light_applies(); if !f.invert_alpha && !linear { return img.clone(); } - let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb); let mut out = img.clone(); for p in out.data.iter_mut() { let a = p[3]; diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index a45fb69b2..c9b8e56fc 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -254,3 +254,43 @@ fn layer_cache_is_keyed_by_colour_settings() { assert!((frame(&p) - a).abs() < 1e-9); assert_eq!(cache.stats().hits, hits + 1); } + +/// #495: with no working space, Interpret As Linear Light footage is encoded with the project's +/// Working Gamma: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4 (as After Effects does); +/// Blend Colors Using 1.0 Gamma linearizes with the same curve. A working space ignores it. +#[test] +fn working_gamma_24_encodes_linear_footage_with_a_power_curve() { + let render = |gamma: f64, ws: Option| { + let (mut p, cid, comp) = setup(BitDepth::Bpc32); + p.settings.working_gamma = gamma; + p.settings.working_space = ws; + let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(Footage { linear_light: true, ..footage(None) })); + let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None); + p.comp_mut(cid).unwrap().layers.push(l); + let src = Flat([0.051, 0.18, 0.5]); + Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5) + }; + let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3); + let o = render(2.4, None); + assert!(near(o, [0.290, 0.490, 0.749]), "2.4: {o:?}"); + let o = render(2.2, None); + assert!(near(o, [0.250, 0.461, 0.735]), "2.2: {o:?}"); + // A working space ignores Working Gamma. + assert_eq!(render(2.4, Some(ColorSpace::Srgb)), render(2.2, Some(ColorSpace::Srgb))); + // Blending with 1.0 gamma: 50% white over black is 0.5 linear, encoded with the curve. + let blend = |gamma: f64| { + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_gamma = gamma; + p.settings.blend_linear = true; + add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 50.0); + px(&p, cid)[0] + }; + assert!((blend(2.4) - 0.5f32.powf(1.0 / 2.4)).abs() < 2e-3, "{}", blend(2.4)); + assert!((blend(2.2) - 0.7354).abs() < 2e-3, "{}", blend(2.2)); + assert_ne!(crate::color::Pipe::of(&Project::default().settings).key(), { + let mut s = Project::default().settings; + s.working_gamma = 2.4; + crate::color::Pipe::of(&s).key() + }); +} diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index e0f17fef1..4445fbbf2 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -82,6 +82,7 @@ fn project(s: &Session) -> J { "linearBlending": p.settings.blend_linear, "linearizeWorkingSpace": p.settings.linearize, "workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(), + "workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 }, "timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames, "renderQueue": p.render_queue.len(), "revision": s.revision, diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 2b8085d3c..f50a968c5 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1696,6 +1696,8 @@ Project.prototype = { set linearizeWorkingSpace(v) { __call("file.projectSettings", { linearize: !!v }); }, get workingSpace() { return this.__info().workingSpace; }, set workingSpace(v) { __call("file.projectSettings", { workingSpace: String(v) || "none" }); }, + get workingGamma() { return this.__info().workingGamma; }, + set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); }, get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; }, set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); }, get expressionEngine() { return "javascript-1.0"; }, diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index c7f330b08..8569e287e 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -546,6 +546,8 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)), ), + // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4). + Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())), Field::bool("linearize", "Linearize working space", st.linearize), Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear), Field::choice( From 5e2c7560d639f7f50986fd15e984dadd22f1b137 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:15:24 +0000 Subject: [PATCH 28/28] Import: only dropped files default to stills; scripts and agents naming a frame still get its run (#431) The UI behaves as in After Effects (the Import dialog's Sequence checkbox starts off, dropped files are stills, dropped folders make sequences), but file.import without sequence or drag keeps importing a numbered still's run, so existing scripts and agents keep working. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/file.rs | 2 +- crates/engine/src/sequence.rs | 12 +++++++----- crates/engine/src/tests_sequence.rs | 6 +++++- docs/agents.md | 8 ++++---- docs/footage.md | 6 ++++-- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index f23475930..55b2c2b68 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -688,7 +688,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; by default picked files import as stills and folders' numbered stills as sequences; false imports a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/sequence.rs index 79142955e..c98aba1d3 100644 --- a/crates/engine/src/sequence.rs +++ b/crates/engine/src/sequence.rs @@ -42,15 +42,17 @@ impl SequenceOptions { SequenceOptions { sequence, folders: sequence, alphabetical: flag("alphabetical").unwrap_or(false) } } - /// `file.import`'s options, as in After Effects: picked or dropped files import as stills - /// unless `sequence: true` (the Import dialog's " Sequence" checkbox, which is off - /// unless ticked), while a folder's numbered stills import as sequences unless - /// `sequence: false` (Alt-drag of a folder). + /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After + /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` + /// (Alt-drag of a folder). The Import dialog passes its " Sequence" checkbox (off + /// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered + /// still brings its run, as it always has. pub fn for_import(p: &serde_json::Value) -> SequenceOptions { let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false)); + let dropped = flag("drag").unwrap_or(false); // Force Alphabetical Order is part of the Sequence option. - SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical } + SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical } } } diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs index d59a92983..a89485f92 100644 --- a/crates/engine/src/tests_sequence.rs +++ b/crates/engine/src/tests_sequence.rs @@ -97,11 +97,15 @@ fn numbered_stills_import_as_stills_by_default() { let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "drag": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.kind), ("shot_0002.png", FootageKind::Still)); - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0004.png")]})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0004.png")], "drag": true})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}"); let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence)); + // Scripts and agents naming one frame without `sequence` still get its run. + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap(); + let (_, f) = footage(&s, &r["items"][0]); + assert_eq!(f.kind, FootageKind::Sequence); } #[test] diff --git a/docs/agents.md b/docs/agents.md index 62fcd2169..c631bc05b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -251,10 +251,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the - file's whole numbered run as one item (several picked frames: that range; a folder, even without - `sequence`: each run in it; without `sequence` picked files import as stills, as in After - Effects; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole + numbered run as one item (several picked frames: that range; a folder: each run in it; + `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless + `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new diff --git a/docs/footage.md b/docs/footage.md index d25dcc342..458c4c990 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -52,9 +52,11 @@ Effects does, so every other frame stays at its number. Render or copy the missi folder and choose File ▸ Reload Footage to fill the gaps. Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`. -Without `sequence`, picked files import as stills and a folder's numbered runs as sequences; +Without `sequence`, a numbered file brings its whole run and a folder's numbered runs import as +sequences, except dropped files (`"drag": true`), which import as stills as in After Effects. `"sequence": true` is the Sequence option (one file brings its whole run), `"sequence": false` -imports a folder's files one by one, and `"alphabetical": true` is Force Alphabetical Order. +imports stills and a folder's files one by one, and `"alphabetical": true` is Force Alphabetical +Order. ## Interpret Footage