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:
bflatasticandClaude Opus 5.5 committed 2026-10-10 00:57:00 +07:00
1 parent 176cddcdad
commit 67305fbcd1
21 files changed
+377 -118

No files matched your search

+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);
}
+4
View File
@@ -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`).
+4 -1
View File
@@ -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.
+4 -1
View File
@@ -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),
+9 -2
View File
@@ -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