mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 02:57:34 -06:00
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:
co-authored by
Claude Opus 5.5
parent
176cddcdad
commit
67305fbcd1
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user