diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 81f30d4f..b6e78870 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -26,9 +26,7 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick return None; } let ctx = r.eval_ctx(comp_id, t)?; - let s = r.opts.scale; - let w = ((ctx.comp.width as f64 * s).round() as u32).max(1); - let h = ((ctx.comp.height as f64 * s).round() as u32).max(1); + let (w, h) = effectcraft_render::frame_size(ctx.comp.width, ctx.comp.height, r.opts.scale); if r.depth() > 16 || !e.g.fits(w, h) { return None; } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index de4023bf..ebe0a018 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -981,8 +981,7 @@ impl<'a> Renderer<'a> { pub fn comp_frame_cpu(&self, comp_id: ItemId, t: Tick) -> Image { let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) }; let s = self.opts.scale; - let w = ((comp.width as f64 * s).round() as u32).max(1); - let h = ((comp.height as f64 * s).round() as u32).max(1); + let (w, h) = frame_size(comp.width, comp.height, s); // Region of interest on a comp with 3D layers: render the frame and crop it. A region // reaching past the comp frame (the Extended Viewer) renders Classic 3D natively — the // planes project through the offset camera — and Advanced 3D as the frame, padded. @@ -2389,6 +2388,12 @@ pub fn extended_region(w: u32, h: u32, visible: [f64; 4], margin: f64) -> Option Some([x0, y0, x1 - x0, y1 - y0]) } +/// The size of a `width` x `height` comp frame rendered at `scale` (both compositors, and the +/// frame size Composition Settings shows for a Resolution). +pub fn frame_size(width: u32, height: u32, scale: f64) -> (u32, u32) { + (((width as f64 * scale).round() as u32).max(1), ((height as f64 * scale).round() as u32).max(1)) +} + pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index fab6c2e0..d803e2e7 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -298,6 +298,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Frame Aspect Ratio: {}", "フレームアスペクト比:{}"), ("Frame Blending: {}", "フレームブレンド:{}"), ("Frame Rate:", "フレームレート:"), + ("Resolution:", "解像度:"), ("Frame Render Time {}ms", "フレームのレンダリング時間 {}ms"), ("Frame Target:", "フレームターゲット:"), ("Freeze", "フリーズ"), @@ -1033,6 +1034,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Frame Aspect Ratio: {}", "帧长宽比:{}"), ("Frame Blending: {}", "帧混合:{}"), ("Frame Rate:", "帧速率:"), + ("Resolution:", "分辨率:"), ("Frame Render Time {}ms", "帧渲染时间 {} 毫秒"), ("Frame Target:", "帧目标:"), ("Freeze", "冻结"), @@ -1773,6 +1775,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Frame Aspect Ratio: {}", "影格長寬比:{}"), ("Frame Blending: {}", "影格混合:{}"), ("Frame Rate:", "影格速率:"), + ("Resolution:", "解析度:"), ("Frame Render Time {}ms", "影格算圖時間 {} 毫秒"), ("Frame Target:", "幀目標:"), ("Freeze", "凍結"), @@ -2467,6 +2470,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Frame Aspect Ratio: {}", "Пропорції кадру: {}"), ("Frame Blending: {}", "Змішування кадрів: {}"), ("Frame Rate:", "Частота кадрів:"), + ("Resolution:", "Роздільність:"), ("Frame Render Time {}ms", "Час рендерингу кадру {} мс"), ("Frame Target:", "Цільовий фрейм:"), ("Freeze", "Заморозити"), @@ -3027,6 +3031,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Frame Aspect Ratio: {}", "Proporção do quadro: {}"), ("Frame Blending: {}", "Mesclagem de quadros: {}"), ("Frame Rate:", "Taxa de quadros:"), + ("Resolution:", "Resolução:"), ("Frame Render Time {}ms", "Tempo de renderização do quadro {}ms"), ("Frame Target:", "Destino do quadro:"), ("Grab bag", "Lista"), @@ -3485,6 +3490,7 @@ const CZECH: &[(&str, &str)] = &[ ("Frame Aspect Ratio: {}", "Poměr stran snímku: {}"), ("Frame Blending: {}", "Prolnutí snímků: {}"), ("Frame Rate:", "Snímková frekvence:"), + ("Resolution:", "Rozlišení:"), ("Frame Render Time {}ms", "Čas vykreslení snímku {}ms"), ("Frame Target:", "Cílový rámec:"), ("Freeze", "Zmrazit"), @@ -4048,6 +4054,7 @@ const SPANISH: &[(&str, &str)] = &[ ("Frame Aspect Ratio: {}", "Relación de aspecto del fotograma: {}"), ("Frame Blending: {}", "Fusión de fotogramas: {}"), ("Frame Rate:", "Velocidad de fotogramas:"), + ("Resolution:", "Resolución:"), ("Frame Render Time {}ms", "Tiempo de procesamiento del fotograma {}ms"), ("Frame Target:", "Marco de destino:"), ("Freeze", "Congelar"), diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5..7e676bcc 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -5,6 +5,7 @@ use crate::i18n::{tr, tr_args}; use crate::icons::Icon; use effectcraft_engine::prefs::CompPreset; +use effectcraft_engine::project::Resolution; use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode}; use egui::{Color32, vec2}; use serde_json::json; @@ -94,6 +95,9 @@ pub struct CompDraft { pub preserve_frame_rate: bool, pub preserve_resolution: bool, pub advanced_3d: bool, + /// The comp's Resolution (the Composition panel's popup): saved with the project but no undo + /// step, so OK sets it apart from `comp.new` / `comp.settings`. + pub resolution: Resolution, pub tab: Tab, /// Timecode text being edited (start, duration). pub start_tc: Option, @@ -122,6 +126,7 @@ impl Default for CompDraft { preserve_frame_rate: false, preserve_resolution: false, advanced_3d: false, + resolution: Resolution::Auto, tab: Tab::Basic, start_tc: None, dur_tc: None, @@ -376,9 +381,30 @@ pub fn show( } }); ui.end_row(); - ui.label(""); - let mb = d.width as f64 * d.height as f64 * 4.0 / (1024.0 * 1024.0); - ui.label(egui::RichText::new(tr_args("{} x {}, {}MB per 8bpc frame", &[&d.width, &d.height, &format!("{mb:.1}")])).color(Color32::GRAY)); + // As in After Effects, the Resolution menu (the Composition panel's popup) with + // the frame it renders beside it. + ui.label(tr("Resolution:")); + ui.horizontal(|ui| { + let r = egui::ComboBox::from_id_salt("comp-resolution").width(90.0).selected_text(tr(d.resolution.label())).show_ui(ui, |ui| { + for res in Resolution::ALL { + ui.selectable_value(&mut d.resolution, res, tr(res.label())); + } + let custom = matches!(d.resolution, Resolution::Custom(_)); + if ui.selectable_label(custom, tr("Custom")).clicked() && !custom { + d.resolution = Resolution::Custom(5); + } + }); + auto.add("dialog.comp.resolution", r.response.rect, "Resolution"); + if let Resolution::Custom(n) = d.resolution { + let mut n = n; + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut n).range(2..=40))); + auto.add("dialog.comp.resolutionFactor", r.rect, "Custom Resolution"); + d.resolution = Resolution::Custom(n); + } + let (w, h) = frame_at(d.width, d.height, d.resolution); + let mb = w as f64 * h as f64 * 4.0 / (1024.0 * 1024.0); + ui.label(egui::RichText::new(tr_args("{} x {}, {}MB per 8bpc frame", &[&w, &h, &format!("{mb:.1}")])).color(Color32::GRAY)); + }); ui.end_row(); ui.label(tr("Start Timecode:")); let r = ui.horizontal(|ui| timecode_field(ui, &mut d.start_tc, &mut d.start, rate, i64::MIN / 4)).inner; @@ -486,10 +512,26 @@ pub fn params(d: &CompDraft) -> serde_json::Value { }) } +/// The frame size the comp renders at `res` (Auto follows the magnification, so the dialog shows +/// the full frame for it). +pub fn frame_at(w: u32, h: u32, res: Resolution) -> (u32, u32) { + effectcraft_render::frame_size(w, h, 1.0 / f64::from(res.factor().unwrap_or(1).max(1))) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn frame_size_follows_the_resolution() { + // The renderer's size, which rounds: 1081 / 3 = 360.33 and 1920 / 7 = 274.3. + assert_eq!(frame_at(1920, 1081, Resolution::Third), (640, 360)); + assert_eq!(frame_at(1920, 1080, Resolution::Custom(7)), (274, 154)); + assert_eq!(frame_at(1920, 1081, Resolution::Half), (960, 541)); + assert_eq!(frame_at(1920, 1080, Resolution::Full), (1920, 1080)); + assert_eq!(frame_at(1920, 1080, Resolution::Auto), (1920, 1080)); + } + #[test] fn aspect_labels_match_ae() { assert_eq!(aspect_label(1920.0, 1080.0), "16:9 (1.78)"); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f4413d14..07b257e2 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -103,6 +103,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<( preserve_frame_rate: c.preserve_frame_rate, preserve_resolution: c.preserve_resolution, advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D, + resolution: c.resolution, ..Default::default() }; app.dialog_state.editing_existing = true; @@ -410,6 +411,11 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis m.insert("comp".into(), json!(cid.0)); } let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; + let cid = if existing { app.dialog_state.editing_comp.map(|c| c.0) } else { r.as_ref().ok().and_then(|v| v["comp"].as_u64()) }; + let r = match cid { + Some(cid) if r.is_ok() => set_resolution(app, cid, app.dialog_state.comp.resolution).and(r), + _ => r, + }; if !existing && r.is_ok() { app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone()); } @@ -422,6 +428,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } } +/// The Resolution chosen in Composition Settings, set as the Composition panel's popup sets it: +/// saved with the project but no undo step, so it stays out of `comp.new` / `comp.settings`. +fn set_resolution(app: &mut EffectcraftApp, cid: u64, r: effectcraft_engine::project::Resolution) -> effectcraft_engine::Result { + match r.factor() { + None => app.session.execute("view.res.auto", json!({"comp": cid})), + Some(n) => app.session.execute("view.res.custom", json!({"comp": cid, "factor": n})), + } +} + /// One frame of the open dialog with `key` pressed. #[cfg(test)] pub(crate) fn press_key(app: &mut EffectcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) { @@ -829,6 +844,59 @@ mod tests { assert_eq!(selected(&ctx), Some(0..app.dialog_state.solid_name.chars().count())); } + /// One frame of Composition Settings (or New Composition) with Enter pressed: OK. + fn press_ok(app: &mut EffectcraftApp, ctx: &egui::Context, existing: bool) { + let t = app.tokens; + let mut out = ctx.run_ui(Default::default(), |ui| { + app.auto.begin_frame(); + ui.input_mut(|i| { + i.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }) + }); + comp_settings(app, ui.ctx(), &t, existing); + }); + out.textures_delta.clear(); + assert!(app.dialog.is_none(), "OK closed the dialog"); + } + + /// The Resolution menu sets the comp's Resolution on OK. As from the Composition panel's popup + /// it is no undo step: the settings are one step, and undoing them keeps the Resolution. + #[test] + fn composition_settings_resolution_applies_on_ok_outside_the_undo_history() { + use effectcraft_engine::project::Resolution; + let (mut app, ctx) = app(); + let cid = app.session.active_comp_id().unwrap(); + app.session.execute("view.res.third", json!({})).unwrap(); + open_comp_settings(&mut app).unwrap(); + assert_eq!(app.dialog_state.comp.resolution, Resolution::Third, "the dialog opens on the comp's Resolution"); + frame(&mut app, &ctx); + assert!(app.auto.find("dialog.comp.resolution").is_some()); + let steps = app.session.history.undo.len(); + (app.dialog_state.comp.resolution, app.dialog_state.comp.width) = (Resolution::Half, 700); + app.dialog_state.comp.lock_aspect = false; + press_ok(&mut app, &ctx, true); + let c = app.session.project.comp(cid).unwrap(); + assert_eq!((c.width, c.resolution), (700, Resolution::Half)); + assert_eq!(app.session.history.undo.len(), steps + 1, "Composition Settings is one undo step"); + app.session.execute("edit.undo", json!({})).unwrap(); + let c = app.session.project.comp(cid).unwrap(); + assert_eq!((c.width, c.resolution), (640, Resolution::Half), "undo keeps the Resolution"); + // Only the Resolution changed: still set, and no extra step. + open_comp_settings(&mut app).unwrap(); + app.dialog_state.comp.resolution = Resolution::Custom(6); + let steps = app.session.history.undo.len(); + press_ok(&mut app, &ctx, true); + assert_eq!(app.session.project.comp(cid).unwrap().resolution, Resolution::Custom(6)); + assert_eq!(app.session.history.undo.len(), steps, "no undo step"); + // New Composition makes its comp at the menu's Resolution; the comp the viewer showed keeps its own. + open_new_comp(&mut app); + app.dialog_state.comp.resolution = Resolution::Quarter; + press_ok(&mut app, &ctx, false); + let new = app.session.active_comp_id().unwrap(); + assert_ne!(new, cid); + assert_eq!(app.session.project.comp(new).unwrap().resolution, Resolution::Quarter); + assert_eq!(app.session.project.comp(cid).unwrap().resolution, Resolution::Custom(6)); + } + #[test] fn composition_settings_register_every_control_and_new_comp_remembers() { let (mut app, ctx) = app(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 43af6354..ff7cc3ef 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -688,8 +688,10 @@ Fixes from measured reports: 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. + and `resolutionFactor` (#417). Composition Settings ▸ Basic has the same Resolution menu, with + the frame size and memory per 8bpc frame at that resolution beside it; OK sets it as the popup + does, outside the undo step of the other settings (#2167). Not yet: the magnification is not + saved per comp. ### Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel