Viewer: each composition keeps its Resolution in the project, as in After Effects (#417)

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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-10 00:57:00 +07:00
co-authored by Claude Opus 5.5
parent 176cddcdad
commit 67305fbcd1
21 changed files with 377 additions and 118 deletions
+1 -6
View File
@@ -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<CommandSpec> {
// 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),
+1
View File
@@ -80,6 +80,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
"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::<Vec<_>>(),
}))
}
+35 -3
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<CommandSpec> {
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),
+2 -1
View File
@@ -334,7 +334,8 @@ impl Session {
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = 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;
+30
View File
@@ -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();
+3
View File
@@ -322,6 +322,9 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
}
"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),
+63
View File
@@ -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);
}
+78 -5
View File
@@ -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<u8> {
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<essential::EssentialGraphics>,
/// 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();
}
+48
View File
@@ -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();
+15 -2
View File
@@ -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),
+9 -10
View File
@@ -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
+12 -34
View File
@@ -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<bool>
"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 {
-1
View File
@@ -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()
}
+2 -2
View File
@@ -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),
+3 -45
View File
@@ -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<f32>,
/// 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,
+52 -2
View File
@@ -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");
}
@@ -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);
}