mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #462 from storytold/fix/417-viewer-resolution
Viewer: each composition keeps its Resolution in the project, as in After Effects (#417) # Conflicts: # crates/ui-egui/src/control.rs # crates/ui-egui/src/menus.rs # crates/ui-egui/src/panels/viewer_tools.rs
This commit is contained in:
commit
b2d05582e0
21 files changed
+378
-118
No files matched your search
@@ -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.
|
||||
@@ -59,11 +59,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),
|
||||
|
||||
@@ -87,6 +87,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<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -341,7 +341,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;
|
||||
|
||||
@@ -707,9 +707,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();
|
||||
@@ -720,6 +749,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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -338,6 +338,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,
|
||||
@@ -391,6 +456,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.
|
||||
@@ -436,6 +506,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
|
||||
@@ -735,8 +806,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 {
|
||||
@@ -765,6 +836,7 @@ impl Comp {
|
||||
global_light,
|
||||
guides,
|
||||
essential,
|
||||
resolution: _,
|
||||
} = self;
|
||||
*width == o.width
|
||||
&& *height == o.height
|
||||
@@ -793,14 +865,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();
|
||||
|
||||
@@ -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];
|
||||
@@ -522,11 +530,16 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
|
||||
let automation_id = app.auto.elements.iter().chain(app.auto.previous.iter()).find(|e| e.response_id == Some(id)).map(|e| e.id.as_str());
|
||||
json!({"id": format!("{:016x}", id.value()), "rect": rect, "automationId": automation_id, "registryCandidates": candidates})
|
||||
});
|
||||
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),
|
||||
|
||||
@@ -826,27 +826,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
|
||||
|
||||
+13
-34
@@ -35,7 +35,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")),
|
||||
@@ -529,10 +528,7 @@ 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 id == "view.theme.toggle" {
|
||||
app.cycle_appearance(ctx);
|
||||
return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode}));
|
||||
@@ -928,33 +924,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),
|
||||
@@ -1450,9 +1432,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)),
|
||||
// The Tools panel is the header toolbar: always on screen.
|
||||
"window.panel" => Some(pstr("panel").is_some_and(|n| n.eq_ignore_ascii_case("tools") || PanelKind::from_name(n).is_some_and(|p| panel_shown(app, p)))),
|
||||
|
||||
@@ -314,7 +314,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);
|
||||
// Window menu check marks: the panels on screen.
|
||||
crate::dock::PanelKind::ALL.iter().filter(|p| crate::menus::panel_shown(app, **p)).for_each(|p| p.hash(&mut h));
|
||||
|
||||
@@ -629,13 +629,13 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
|
||||
// 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);
|
||||
let response = widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res"))
|
||||
let res = app.viewer_res();
|
||||
let response = 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"));
|
||||
if response.clicked() {
|
||||
toggle_popup(ui, "vw-res-pop");
|
||||
}
|
||||
app.auto.add_response("viewer.resolution", &response, "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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
@@ -1878,7 +1928,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();
|
||||
@@ -1887,7 +1937,7 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,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`).
|
||||
|
||||
@@ -194,7 +194,10 @@ are dropped; running jobs finish into the cache. When the budget (Settings ▸ M
|
||||
full the least recently shown frames go first. The timeline's green bar counts cached frames
|
||||
of the current content and viewer settings.
|
||||
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.
|
||||
|
||||
@@ -134,7 +134,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),
|
||||
|
||||
@@ -681,8 +681,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
|
||||
|
||||
|
||||
Reference in new issue
Block a user