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
+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,