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
@@ -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),
|
||||
|
||||
@@ -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<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user