From 67305fbcd1ebde759ea46bdba6f8555bbe58d6d7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 00:57:00 +0700 Subject: [PATCH] Viewer: each composition keeps its Resolution in the project, as in After Effects (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: the Composition panel's Resolution (Auto, Full, Half, Third, Quarter, Custom) is now a property of the composition (`Comp::resolution`, moved with its `Resolution` type from the UI into the project crate) instead of one global viewer setting. Switching to another comp shows that comp's resolution, switching back shows the first one's again, and saving and reopening the project keeps them. `view.res.full|half|third|quarter|custom` and the new engine command `view.res.auto` set it (headless too, with an optional `comp`); `ui.set {viewer: {res}}` sets the active comp's and `ui.inspect` still reports `ui.viewer.res`; `comp.info` reports `resolution` and `resolutionFactor`; View ▸ Resolution's check marks come from the engine. Why: After Effects saves each composition's Resolution with the project (CompItem.resolutionFactor is a per-comp attribute). As there, changing it marks the project modified but adds no undo step: undo, redo and History jumps carry the current values over to the restored state. It is not part of the comp's pixels (`Comp::same_pixels` ignores it and `Comp::pixel_form` normalises it), so changing it keeps the comp's RAM and disk cache keys. Auto is not written (serde default and skip_serializing_if), so projects saved before open with Auto and older versions read new files. The magnification is not saved per comp: Adobe's documentation doesn't say it is. Tests: project `comp_resolution_is_saved_and_old_projects_load_with_auto`, `resolution_factors_and_scales`; engine `each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file` (switching comps, no undo step, undo/redo keep it, save/open round trip, menu check marks, Custom dialog); ui-egui `each_comp_keeps_its_viewer_resolution` (the viewer popup on comp A, comp B through `ui.set`, back to A, unchanged frame cache identity, save/open). They don't build before the change (no `Comp::resolution`, no `view.res.auto` engine command). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/frontend.rs | 7 +- crates/engine/src/commands/query.rs | 1 + crates/engine/src/commands/view.rs | 38 ++++++++- crates/engine/src/history.rs | 3 +- crates/engine/src/lib.rs | 30 +++++++ crates/engine/src/menus.rs | 3 + crates/engine/src/tests_viewer.rs | 63 ++++++++++++++ crates/project/src/lib.rs | 83 +++++++++++++++++-- crates/project/src/tests.rs | 48 +++++++++++ crates/ui-egui/src/control.rs | 17 +++- crates/ui-egui/src/lib.rs | 19 ++--- crates/ui-egui/src/menus.rs | 46 +++------- crates/ui-egui/src/native_menu.rs | 1 - crates/ui-egui/src/panels/viewer_tools.rs | 4 +- crates/ui-egui/src/state.rs | 48 +---------- crates/ui-egui/tests/ui_viewer.rs | 54 +++++++++++- crates/ui-egui/tests/ui_workflow_selection.rs | 5 +- docs/agents.md | 4 + docs/architecture.md | 5 +- docs/control-protocol.md | 5 +- docs/parity.md | 11 ++- 21 files changed, 377 insertions(+), 118 deletions(-) diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 296160d1..f498de45 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -1,4 +1,4 @@ -//! Frontend-only commands: viewer zoom/resolution/overlays, panels, workspaces, dialogs and app +//! Frontend-only commands: viewer zoom/overlays, panels, workspaces, dialogs and app //! chrome. They live in the registry so every menu entry is a command (menus, shortcuts, the //! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI //! performs it. Headless sessions accept and ignore them. @@ -57,11 +57,6 @@ pub fn specs() -> Vec { // Viewer. fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp), fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp), - fe!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), "{}", has_comp), - fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp), - fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp), - fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp), - fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp), fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always), fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always), fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always), diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 27588907..2e586a8e 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -80,6 +80,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result { "id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(), "workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background, "preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution, + "resolution": c.resolution.label(), "resolutionFactor": c.resolution.factor(), "layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::>(), })) } diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/commands/view.rs index 4cb001f6..abf92416 100644 --- a/crates/engine/src/commands/view.rs +++ b/crates/engine/src/commands/view.rs @@ -1,7 +1,7 @@ -//! View menu commands that change the project (guides) or headless-relevant editor state (the -//! region of interest). +//! View menu commands that change the project (guides, each comp's Resolution) or +//! headless-relevant editor state (the region of interest). -use effectcraft_project::Guide; +use effectcraft_project::{Guide, Resolution}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p}; @@ -75,6 +75,24 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.region_of_interest)) } +/// View ▸ Resolution (and the Composition panel's Resolution popup): the comp's Resolution, saved +/// with the project but no undo step ([`Session::set_resolution`]). +fn set_resolution(s: &mut Session, p: &Value, r: Resolution) -> Result { + let cid = comp_id(s, p)?; + s.set_resolution(cid, r)?; + Ok(json!({"comp": cid.0, "resolution": r.label(), "factor": r.factor()})) +} + +/// View ▸ Resolution ▸ Custom: `factor` renders every n-th pixel; without one the frontend asks +/// for it (the Custom Resolution dialog). +fn custom_resolution(s: &mut Session, p: &Value) -> Result { + match f_p(p, "factor") { + Some(n) => set_resolution(s, p, Resolution::from_factor(n.round().clamp(1.0, 40.0) as u64)), + None if p.get("factor").is_some_and(|v| !v.is_null()) => Err(bad("view.res.custom", "factor: a number from 1 to 40")), + None => super::frontend(s, "view.res.custom", p), + } +} + fn view_layout(s: &mut Session, p: &Value) -> Result { let n = p.get("views").and_then(Value::as_u64).ok_or_else(|| bad("view.layout", "views: 1|2|4"))?; if !matches!(n, 1 | 2 | 4) { @@ -101,12 +119,26 @@ fn extended_viewer(s: &mut Session, p: &Value) -> Result { Ok(json!(v)) } +/// A `view.res.*` command setting one [`Resolution`]. +macro_rules! res { + ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $r:ident) => { + cmd!($id, $label, [$($m),*], $sc, "{comp?}", has_comp, |s, p| set_resolution(s, p, Resolution::$r)) + }; +} + pub fn specs() -> Vec { vec![ cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide), cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides), cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides), cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides), + res!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), Full), + res!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), Half), + res!("view.res.third", "Third", ["View", "Resolution"], None, Third), + res!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), Quarter), + cmd!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel), comp?}", has_comp, custom_resolution), + // The Composition panel's Resolution popup (not in the View menu). + res!("view.res.auto", "Resolution: Auto", [], None, Auto), cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout), cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options), cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer), diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 36630969..e85315fd 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -334,7 +334,8 @@ impl Session { let k = new_line.iter().position(|&i| i == target).unwrap_or(0); let undo: Vec<(String, Arc)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect(); let redo: Vec<(String, Arc)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect(); - self.project = a.state[target].clone(); + let cur = std::mem::replace(&mut self.project, a.state[target].clone()); + self.keep_view_settings(&cur); self.history.undo = undo; self.history.redo = redo; self.history.branches = branches; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 24ccf1af..f1eb9d82 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -650,9 +650,38 @@ impl Session { self.events.push(Event::ProjectChanged { revision: self.revision }); } + /// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes + /// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay + /// valid ([`effectcraft_project::Comp::same_pixels`]). + pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> { + if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r { + return Ok(()); + } + Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r; + self.bump(); + Ok(()) + } + + /// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the + /// project before undo, redo or a history jump restored another state. + pub(crate) fn keep_view_settings(&mut self, from: &Project) { + let keep: Vec<(ItemId, effectcraft_project::Resolution)> = + self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect(); + if keep.is_empty() { + return; + } + let p = Arc::make_mut(&mut self.project); + for (id, r) in keep { + if let Some(c) = p.comp_mut(id) { + c.resolution = r; + } + } + } + pub fn undo(&mut self) -> bool { let Some((label, p)) = self.history.undo.pop() else { return false }; let cur = std::mem::replace(&mut self.project, p); + self.keep_view_settings(&cur); self.history.redo.push((label, cur)); self.history.merge_key = None; self.sanitize_state(); @@ -663,6 +692,7 @@ impl Session { pub fn redo(&mut self) -> bool { let Some((label, p)) = self.history.redo.pop() else { return false }; let cur = std::mem::replace(&mut self.project, p); + self.keep_view_settings(&cur); self.history.undo.push((label, cur)); self.history.merge_key = None; self.sanitize_state(); diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index c5830828..a236fa37 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -322,6 +322,9 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { } "layer.markersLock" => Some(layer?.markers_locked), "layer.mask.hideLocked" => Some(s.state.hide_locked_masks), + "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" | "view.res.custom" => { + Some(comp.resolution.label().eq_ignore_ascii_case(command.trim_start_matches("view.res."))) + } "view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)), "view.shareViewOptions" => Some(s.state.share_view_options), "view.extendedViewer" => Some(s.prefs.three_d.extended_viewer), diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index a6139668..be3edf46 100644 --- a/crates/engine/src/tests_viewer.rs +++ b/crates/engine/src/tests_viewer.rs @@ -574,3 +574,66 @@ fn graph_transform_box_scales_key_times_and_values() { assert_eq!(p.keys[1].value.as_f64(), 50.0); assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err()); } + +// ---------------------------------------------------------------- resolution + +#[test] +fn each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file() { + use effectcraft_project::Resolution; + let mut s = comp(); + let a = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"name": "S", "color": "#ff0000", "width": 10, "height": 10})).unwrap(); + let path = std::env::temp_dir().join(format!("ec-resolution-{}.ecproj", std::process::id())); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto); + let (steps, pixels) = (s.history.undo.len(), s.active_comp_arc().unwrap()); + let r = s.execute_checked("view.res.half", json!({})).unwrap(); + assert_eq!((r["resolution"].clone(), r["factor"].clone()), (json!("Half"), json!(2))); + // No undo step, but the project is modified (it's saved with it). + assert_eq!(s.history.undo.len(), steps); + assert!(s.is_dirty()); + assert!(s.active_comp().unwrap().same_pixels(&pixels)); + // Another comp has its own; switching back shows comp A's again. + s.execute("comp.new", json!({"name": "B", "width": 320, "height": 180, "frameRate": 30, "duration": 5})).unwrap(); + let b = s.active_comp_id().unwrap(); + assert_ne!(a, b); + assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto); + s.execute_checked("view.res.custom", json!({"factor": 6})).unwrap(); + s.open_comp(a); + assert_eq!(s.active_comp().unwrap().resolution, Resolution::Half); + assert_eq!(s.execute("comp.info", json!({})).unwrap()["resolution"], json!("Half")); + assert_eq!(crate::menus::checked(&s, "view.res.half", &json!({})), Some(true)); + assert_eq!(crate::menus::checked(&s, "view.res.full", &json!({})), Some(false)); + // Undo and redo restore the comps, not their resolutions. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.comp(b).is_none(), "undid New Composition"); + assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.comp(b).unwrap().resolution, Resolution::Custom(6)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layers.is_empty(), "undid the solid"); + assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half); + s.execute("edit.redo", json!({})).unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + // The comp's explicit `comp` parameter; the same value again changes nothing. + s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap(); + let rev = s.revision; + s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap(); + assert_eq!(s.revision, rev); + // Saved with the project and read back. + s.execute("file.save", json!({})).unwrap(); + assert!(!s.is_dirty()); + let mut o = Session::default(); + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(o.project.comp(a).unwrap().resolution, Resolution::Half); + assert_eq!(o.project.comp(b).unwrap().resolution, Resolution::Quarter); + let _ = std::fs::remove_file(path); + // Custom without a factor asks the frontend (the Custom Resolution dialog). + s.drain_events(); + s.execute_checked("view.res.custom", json!({})).unwrap(); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "view.res.custom"))); + assert!(s.execute_checked("view.res.custom", json!({"factor": "big"})).is_err()); + s.execute_checked("view.res.auto", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 55b7e552..2e3d910f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -283,6 +283,71 @@ pub enum Renderer { Advanced3D, } +/// A composition's Resolution (the Composition panel's Resolution/Down Sample Factor popup, View ▸ +/// Resolution): how many of its pixels the viewer renders. Auto follows the magnification. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Resolution { + #[default] + Auto, + Full, + Half, + Third, + Quarter, + /// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40). + Custom(u8), +} + +impl Resolution { + pub const ALL: [Resolution; 5] = [Resolution::Auto, Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter]; + pub fn label(self) -> &'static str { + match self { + Resolution::Auto => "Auto", + Resolution::Full => "Full", + Resolution::Half => "Half", + Resolution::Third => "Third", + Resolution::Quarter => "Quarter", + Resolution::Custom(_) => "Custom", + } + } + pub fn is_auto(&self) -> bool { + *self == Resolution::Auto + } + /// The down-sample factor (render every n-th pixel); `None` for Auto. + pub fn factor(self) -> Option { + match self { + Resolution::Auto => None, + Resolution::Full => Some(1), + Resolution::Half => Some(2), + Resolution::Third => Some(3), + Resolution::Quarter => Some(4), + Resolution::Custom(n) => Some(n), + } + } + /// The resolution that renders every `n`-th pixel (1 = Full … 4 = Quarter; up to 40). + pub fn from_factor(n: u64) -> Resolution { + match n.clamp(1, 40) { + 1 => Resolution::Full, + 2 => Resolution::Half, + 3 => Resolution::Third, + 4 => Resolution::Quarter, + n => Resolution::Custom(n as u8), + } + } + /// Render scale for a viewer magnification (and display pixel density). + pub fn scale(self, zoom: f32, ppp: f32) -> f64 { + match self.factor() { + Some(n) => 1.0 / f64::from(n.clamp(1, 40)), + // As in After Effects, Auto renders the pixels the magnification needs: the coarsest + // whole factor that still gives every screen pixel a rendered one (Full above 50 %, + // Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417). + None => { + let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor(); + if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 } + } + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Comp { pub width: u32, @@ -336,6 +401,11 @@ pub struct Comp { /// Essential Graphics: the controls this comp exposes to instances and templates. #[serde(default, skip_serializing_if = "Option::is_none")] pub essential: Option, + /// The Composition panel's Resolution, saved with the project as in After Effects. It changes + /// what the viewer renders, not the comp's pixels ([`Comp::same_pixels`]), and stays out of + /// the undo history. + #[serde(default, skip_serializing_if = "Resolution::is_auto")] + pub resolution: Resolution, } /// A viewer guide line: vertical guides sit at an x position, horizontal ones at a y position. @@ -381,6 +451,7 @@ impl Comp { guides: vec![], global_light: styles::GlobalLight::default(), essential: None, + resolution: Resolution::Auto, } } /// Whether the composition has a frame at its time `t`: nested, it shows nothing before it @@ -680,8 +751,8 @@ impl Layer { } impl Comp { - /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches and - /// Hide Shy Layers. + /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide + /// Shy Layers and the viewer's Resolution. pub fn same_pixels(&self, o: &Comp) -> bool { // Every field (no `..`: a new field must be considered here); the layers last. let Comp { @@ -710,6 +781,7 @@ impl Comp { global_light, guides, essential, + resolution: _, } = self; *width == o.width && *height == o.height @@ -738,14 +810,15 @@ impl Comp { && layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) } - /// The comp with those switches at their defaults (borrowed when they already are), for - /// keys that hash the whole comp. + /// The comp with those switches and the Resolution at their defaults (borrowed when they + /// already are), for keys that hash the whole comp. pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> { - if !self.hide_shy && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { + if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { return std::borrow::Cow::Borrowed(self); } let mut c = self.clone(); c.hide_shy = false; + c.resolution = Resolution::Auto; for l in &mut c.layers { l.switches = l.switches.pixels(); } diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index edf9a256..eb1c40a3 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -65,6 +65,54 @@ fn animate_and_roundtrip_json() { let _ = Keyframe::new(Tick::ZERO, Value::Scalar(0.0)); } +#[test] +fn comp_resolution_is_saved_and_old_projects_load_with_auto() { + use crate::Resolution; + let (mut p, cid) = project_with_layer(); + // Auto (the default) isn't written, so older versions read the file as before. + assert!(!p.to_file_json().unwrap().contains("\"resolution\"")); + p.comp_mut(cid).unwrap().resolution = Resolution::Custom(5); + let q = Project::from_json(&p.to_file_json().unwrap()).unwrap(); + assert_eq!(q.comp(cid).unwrap().resolution, Resolution::Custom(5)); + p.comp_mut(cid).unwrap().resolution = Resolution::Half; + let text = p.to_file_json().unwrap(); + assert_eq!(Project::from_json(&text).unwrap().comp(cid).unwrap().resolution, Resolution::Half); + // A project saved before comps had a resolution opens with Auto. + fn strip(v: &mut serde_json::Value) { + match v { + serde_json::Value::Object(m) => { + m.remove("resolution"); + m.values_mut().for_each(strip); + } + serde_json::Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut old: serde_json::Value = serde_json::from_str(&text).unwrap(); + strip(&mut old); + assert_eq!(Project::from_json(&old.to_string()).unwrap().comp(cid).unwrap().resolution, Resolution::Auto); + // It's not part of the comp's pixels. + let (a, b) = (p.comp(cid).unwrap(), q.comp(cid).unwrap()); + assert!(a != b && a.same_pixels(b)); + assert_eq!(format!("{:?}", a.pixel_form()), format!("{:?}", b.pixel_form())); +} + +#[test] +fn resolution_factors_and_scales() { + use crate::Resolution; + assert_eq!(Resolution::from_factor(1), Resolution::Full); + assert_eq!(Resolution::from_factor(4), Resolution::Quarter); + assert_eq!(Resolution::from_factor(0), Resolution::Full); + assert_eq!(Resolution::from_factor(7), Resolution::Custom(7)); + assert_eq!(Resolution::from_factor(1000), Resolution::Custom(40)); + assert_eq!(Resolution::Custom(5).factor(), Some(5)); + assert_eq!(Resolution::Auto.factor(), None); + // A hostile factor from a file renders within the Custom range. + assert_eq!(Resolution::Custom(0).scale(1.0, 1.0), 1.0); + assert_eq!(Resolution::Custom(255).scale(1.0, 1.0), 1.0 / 40.0); + assert_eq!(Resolution::Third.scale(0.1, 1.0), 1.0 / 3.0); +} + #[test] fn unique_names_and_cycles() { let (p, cid) = project_with_layer(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 617f0096..669ecd30 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -223,8 +223,16 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(z) = v.get("zoom") { app.ui.viewer.zoom = z.as_f64().map(|z| z as f32); } + // The active comp's Resolution (`view.res.*`), saved with the project. if let Some(r) = v.get("res").and_then(Value::as_str) { - app.ui.viewer.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).unwrap_or(app.ui.viewer.res); + let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else { + return err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)")); + }; + if let Some(cid) = app.session.active_comp_id() + && let Err(e) = app.session.set_resolution(cid, res) + { + return err(e); + } } if let Some(Value::Array(a)) = v.get("pan") { app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32]; @@ -507,11 +515,16 @@ pub fn base64(data: &[u8]) -> String { pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); + let mut ui = json!(app.ui); + // The viewer's Resolution is the active comp's (kept in the project). + if let Some(v) = ui.get_mut("viewer").and_then(Value::as_object_mut) { + v.insert("res".into(), json!(app.viewer_res())); + } json!({ "window": [size.x, size.y], "pixelsPerPoint": ctx.pixels_per_point(), "fps": app.fps, - "ui": app.ui, + "ui": ui, "playing": app.playback.playing, "time": app.session.time().seconds(), "activeComp": app.session.state.active_comp.map(|i| i.0), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82be8a32..8d60e951 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -657,27 +657,26 @@ impl EffectcraftApp { self.viewer_image.clone() } + /// The viewer's Resolution: the active composition's (saved with the project). + pub fn viewer_res(&self) -> state::Resolution { + self.session.active_comp().map(|c| c.resolution).unwrap_or_default() + } + /// The render scale used by the viewer right now. pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 { // The preview's Resolution (when not Auto) while it plays. if self.playback.playing && let Some(f) = self.playback.res { - let r = match f { - 1 => state::Resolution::Full, - 2 => state::Resolution::Half, - 3 => state::Resolution::Third, - 4 => state::Resolution::Quarter, - n => state::Resolution::Custom(n.min(40) as u8), - }; - return r.scale(zoom, ppp); + return state::Resolution::from_factor(f.into()).scale(zoom, ppp); } // Auto follows the zoom alike while playing and paused, so frames cached while scrubbing // play back, and frames cached by a preview stay on screen when it stops. - let full = self.ui.viewer.res.scale(zoom, ppp); + let res = self.viewer_res(); + let full = res.scale(zoom, ppp); // Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging. let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting); - if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto { + if k < 1.0 && res.is_auto() { return full.min((full * 0.5).max(self.session.prefs.adaptive_limit())); } full diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a31f4b39..ee09ae7a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -34,7 +34,6 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("playback.stop", "Stop", [], None), uic!("view.fit", "Fit", [], Some("Shift+/")), uic!("view.actualSize", "100%", [], Some("/")), - uic!("view.res.auto", "Resolution: Auto", [], None), uic!("viewer.nudge.up", "Move Selected Layers Up 1 Pixel at Current Magnification", [], Some("ArrowUp")), uic!("viewer.nudge.down", "Move Selected Layers Down 1 Pixel at Current Magnification", [], Some("ArrowDown")), uic!("viewer.nudge.left", "Move Selected Layers Left 1 Pixel at Current Magnification", [], Some("ArrowLeft")), @@ -459,10 +458,6 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V crate::panels::markers_ui::open_dialog(app, crate::panels::markers_ui::MarkerRef { layer, index })?; return Ok(json!({"dialog": id})); } - if id == "view.res.auto" { - app.ui.viewer.res = Resolution::Auto; - return Ok(Value::Null); - } if let Some(th) = id.strip_prefix("view.theme.") { let k = crate::theme::ThemeKind::from_name(th).ok_or("unknown theme")?; app.set_theme(ctx, k); @@ -825,33 +820,19 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu v.zoom = Some(next); json!({"zoom": next}) } - "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => { - let r = id.trim_start_matches("view.res."); - v.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).ok_or("unknown resolution")?; - Value::Null - } + // View ▸ Resolution ▸ Custom without a factor (the engine sets the comp's resolution). "view.res.custom" => { - match p.get("factor").and_then(Value::as_u64) { - Some(n) => { - v.res = match n.clamp(1, 40) { - 1 => Resolution::Full, - n => Resolution::Custom(n as u8), - }; - } - None => { - let cur = match v.res { - Resolution::Custom(n) => n as f64, - _ => 2.0, - }; - crate::panels::dialogs::form( - app, - "Custom Resolution", - "view.res.custom", - json!({}), - vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)], - ); - } - } + let cur = match app.viewer_res() { + Resolution::Custom(n) => f64::from(n), + _ => 2.0, + }; + crate::panels::dialogs::form( + app, + "Custom Resolution", + "view.res.custom", + json!({}), + vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)], + ); Value::Null } "view.rulers" => toggle(&mut v.rulers, &p), @@ -1274,9 +1255,6 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option "view.layerControls" => Some(v.show_layer_controls), "playback.cacheWhenIdle" => Some(app.ui.cache_when_idle), "playback.audio" => Some(app.session.prefs.preview.active().include_audio), - "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => { - Some(v.res.label().eq_ignore_ascii_case(e.command.trim_start_matches("view.res."))) - } "window.workspace" => Some(pstr("name").is_some_and(|n| n == app.ui.workspace)), "view.panelBackground" if e.params.get("pick").is_none() => { let cur = match v.pasteboard { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index b2509341..2bd0b637 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -310,7 +310,6 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); - v.res.label().hash(&mut h); (app.ui.cache_when_idle, app.session.prefs.preview.active().include_audio, &app.ui.workspace, app.dialog.is_some()).hash(&mut h); h.finish() } diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 8d41eb73..c2d6946a 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -574,14 +574,14 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, // Resolution / down-sample factor. let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0)); let scale = app.viewer_scale(zoom, ppp); - if widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res")) + let res = app.viewer_res(); + if widgets::dropdown(ui, r, &resolution_label(res, scale), &t, egui::Id::new("vw-res")) .on_hover_text(crate::i18n::tr("Resolution/Down Sample Factor Popup")) .clicked() { toggle_popup(ui, "vw-res-pop"); } app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor"); - let res = app.ui.viewer.res; let items: Vec<(String, bool)> = vec![ ("Full".into(), res == Resolution::Full), ("Half".into(), res == Resolution::Half), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0cce2c11..27017acb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -224,49 +224,9 @@ impl Tool { } } -/// Viewer resolution (Auto follows the magnification). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Resolution { - #[default] - Auto, - Full, - Half, - Third, - Quarter, - /// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40). - Custom(u8), -} - -impl Resolution { - pub const ALL: [Resolution; 5] = [Resolution::Auto, Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter]; - pub fn label(self) -> &'static str { - match self { - Resolution::Auto => "Auto", - Resolution::Full => "Full", - Resolution::Half => "Half", - Resolution::Third => "Third", - Resolution::Quarter => "Quarter", - Resolution::Custom(_) => "Custom", - } - } - /// Render scale for a viewer magnification (and display pixel density). - pub fn scale(self, zoom: f32, ppp: f32) -> f64 { - match self { - // As in After Effects, Auto renders the pixels the magnification needs: the coarsest - // whole factor that still gives every screen pixel a rendered one (Full above 50 %, - // Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417). - Resolution::Auto => { - let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor(); - if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 } - } - Resolution::Full => 1.0, - Resolution::Half => 0.5, - Resolution::Third => 1.0 / 3.0, - Resolution::Quarter => 0.25, - Resolution::Custom(n) => 1.0 / n.max(1) as f64, - } - } -} +/// The viewer's Resolution: each composition keeps its own in the project +/// ([`effectcraft_engine::project::Comp::resolution`], `EffectcraftApp::viewer_res`). +pub use effectcraft_engine::project::Resolution; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] @@ -275,7 +235,6 @@ pub struct ViewerState { pub zoom: Option, /// Pan offset in points from the centred position. pub pan: [f32; 2], - pub res: Resolution, pub transparency_grid: bool, pub show_masks: bool, pub safe_margins: bool, @@ -314,7 +273,6 @@ impl Default for ViewerState { ViewerState { zoom: None, pan: [0.0, 0.0], - res: Resolution::Auto, transparency_grid: false, show_masks: true, safe_margins: false, diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index cfb1db37..5d6088ac 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -162,6 +162,56 @@ fn viewer_press_prefers_a_selected_layer_under_the_pointer() { assert_eq!(h.state().session.state.selected_layers, vec![front], "nothing selected: the topmost layer"); } +/// Each composition keeps its Resolution, saved with the project as in After Effects: chosen for +/// comp A in the viewer's popup, it is still A's after showing comp B, costs no undo step, keeps +/// A's cached frames and is read back from the saved file (#417). +#[test] +fn each_comp_keeps_its_viewer_resolution() { + use effectcraft_ui_egui::state::Resolution; + let mut h = harness(); + let a = h.state().session.active_comp_id().unwrap(); + let content = |h: &Harness<'_, EffectcraftApp>, c| { + let s = h.state(); + s.frames.content_of(&s.session.project, s.session.revision, c) + }; + let (before, steps) = (content(&h, a), h.state().session.history.undo.len()); + for id in ["viewer.resolution", "viewer.resolutionItem.1"] { + let at = rect(&h, id).center(); + click(&mut h, at); + } + assert_eq!(h.state().viewer_res(), Resolution::Half); + assert_eq!(h.state().session.project.comp(a).unwrap().resolution, Resolution::Half); + assert_eq!(h.state().session.history.undo.len(), steps, "no undo step"); + assert_eq!(content(&h, a), before, "the comp's cached frames stay valid"); + // Comp B starts at Auto and gets its own (here through the control channel's `ui.set`). + h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap(); + h.run_steps(3); + let b = h.state().session.active_comp_id().unwrap(); + assert_ne!(a, b); + assert_eq!(h.state().viewer_res(), Resolution::Auto); + let (req, _reply) = effectcraft_ui_egui::control::ControlRequest::new("ui.set", json!({"viewer": {"res": "quarter"}})); + let ctx = h.ctx.clone(); + let _ = effectcraft_ui_egui::control::handle(h.state_mut(), &ctx, &req); + assert_eq!(h.state().viewer_res(), Resolution::Quarter); + let ctx = h.ctx.clone(); + assert_eq!(effectcraft_ui_egui::control::inspect(h.state(), &ctx)["ui"]["viewer"]["res"], json!("Quarter")); + // Back to comp A: its resolution again. + h.state_mut().session.open_comp(a); + h.run_steps(3); + assert_eq!(h.state().viewer_res(), Resolution::Half); + assert!(h.state().viewer_scale(1.0, 1.0) == 0.5); + // Saved and opened again. + let path = std::env::temp_dir().join(format!("ec-viewer-res-{}.ecproj", std::process::id())); + h.state_mut().session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut s = Session::default(); + s.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_file(&path); + s.open_comp(b); + let app = EffectcraftApp::new(s); + assert_eq!(app.viewer_res(), Resolution::Quarter); + assert_eq!(app.session.project.comp(a).unwrap().resolution, Resolution::Half); +} + #[test] fn bottom_bar_in_after_effects_order() { let h = harness(); @@ -1610,7 +1660,7 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); // (egui's font atlas needs 1024.) h.input_mut().max_texture_side = Some(1024); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full; + h.state_mut().session.execute("view.res.full", json!({})).unwrap(); let full = |h: &mut Harness<'_, EffectcraftApp>| h.state_mut().viewer_pixels().is_some_and(|px| px.size == [2400, 400]); for _ in 0..400 { h.step(); @@ -1619,6 +1669,6 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { } std::thread::sleep(std::time::Duration::from_millis(5)); } - assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res); + assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().viewer_res()); assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3"); } diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 7b1ca768..42b4032e 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -177,7 +177,6 @@ fn effect_scrubbing_uses_adaptive_resolution_and_settles_on_release() { h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); h.state_mut().session.execute("effect.apply", json!({"effect":"Gaussian Blur"})).unwrap(); h.state_mut().session.execute("view.fastPreviewMode", json!({"mode":"adaptive"})).unwrap(); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"effectControls"})).unwrap(); h.run_steps(3); @@ -188,12 +187,12 @@ fn effect_scrubbing_uses_adaptive_resolution_and_settles_on_release() { h.run_steps(2); assert!(h.state().ui.viewer.property_interacting); assert!(h.state().viewer_scale(1.0, 1.0) < 1.0); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full; + h.state_mut().session.execute("view.res.full", json!({})).unwrap(); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); pointer(&mut h, a + vec2(30.0, 0.0), false, Modifiers::NONE); h.run_steps(2); assert!(!h.state().ui.viewer.property_interacting); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; + h.state_mut().session.execute("view.res.auto", json!({})).unwrap(); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); } diff --git a/docs/agents.md b/docs/agents.md index 71d52afe..45d24b0b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -180,6 +180,10 @@ build real projects through these interfaces; they are worked examples of everyt * **Nested comps**: `comp.settings {preserveFrameRate, preserveResolution}` (Composition Settings ▸ Advanced); `comp.info` / `get_comp` report both. A precomp layer shows nothing outside its nested comp's span. +* **Viewer Resolution** belongs to each comp and is saved with the project, as in After Effects: + `view.res.full|half|third|quarter|auto {comp?}` or `view.res.custom {factor: 1..40, comp?}` + (headless too; no undo step, the project becomes modified). `comp.info` reports `resolution` + and `resolutionFactor` (`null` for Auto). * **Several footage items at once**: `file.newCompFromSelection {single, dimensionsFrom, duration, sequence, overlap, overlapDuration, transition, addToRenderQueue}` is one undo step (select the items first: `project.select`). diff --git a/docs/architecture.md b/docs/architecture.md index 33e38215..23e684fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -172,7 +172,10 @@ options are never shown. When the budget (Settings ▸ Memory & CPU) is full the shown frames go first, and frames of an older revision are dropped as soon as the project changes. The timeline's green bar counts the frames of exactly what the viewer shows. Resolution Auto renders the pixels the magnification needs, as in After Effects (Full above -50 %, Half down to 33.3 %, Third down to 25 %, then Quarter). Below 100 % the viewer averages +50 %, Half down to 33.3 %, Third down to 25 %, then Quarter). The Resolution is the comp's +(`Comp::resolution`, saved with the project): setting it is no undo step (undo, redo and history +jumps carry the current values over) and, like the Shy switches, it is outside +`Comp::same_pixels`, so cached frames stay valid. Below 100 % the viewer averages instead of skipping pixels (Viewer Zoom Quality More Accurate): GPU frames carry a mip chain sampled trilinearly, CPU frames go up averaged down by the whole factor that leaves about one texel per screen pixel. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9d785bd1..16252518 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -114,7 +114,10 @@ Viewer state that agents drive headless too: `view.snapping`, `view.snappingOpti `header.snappingOptions`: After Effects' Snap Edges Extended and Snap to Features in Collapsed Compositions and Text Layers), `view.channel {channel, colorized?}`, `view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`, -`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`, +`view.fastPreviewMode {mode}`, `view.res.full|half|third|quarter|auto {comp?}` and +`view.res.custom {factor, comp?}` (the comp's Resolution, saved with the project but no undo +step; `ui.set {viewer: {res}}` sets the active comp's, and `ui.inspect` reports it as +`ui.viewer.res`), `view.setRegionOfInterest {rect}`, `view.addGuide`, `view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers), `shape.newShape` (the shape tools: a new group in the selected shape layer, else a new shape layer), `shape.toolOptions` (the Tools bar's Tool Creates Shape / Mask and Fill and Stroke Options), diff --git a/docs/parity.md b/docs/parity.md index c89bee97..e2739504 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -417,8 +417,15 @@ Fixes from measured reports: too large (#390). - The viewer's Auto resolution renders the pixels the magnification needs (Full above 50 %), and frames shown below 100 % are averaged (GPU frames through mip levels, CPU frames by a whole - factor) instead of minified bilinearly (#417). Not yet: the viewer resolution and magnification - are not remembered between launches. + factor) instead of minified bilinearly (#417). +- Each composition keeps its Resolution (Auto, Full, Half, Third, Quarter or Custom) in the + project, as in After Effects: switching to another comp and back, or saving and reopening the + project, shows the same resolution. Changing it marks the project modified but adds no undo + step (undo and redo keep it) and keeps the comp's cached frames; projects saved before open + with Auto. View ▸ Resolution and the panel's popup run `view.res.full|half|third|quarter|auto` + and `view.res.custom {factor}`, which now work headless too; `comp.info` reports `resolution` + and `resolutionFactor` (#417). Not yet: the magnification is not saved per comp, and the + Composition Settings dialog has no Resolution popup. ## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel