Composition Settings: Resolution menu with the frame size and memory at that resolution (fixes #2167)

This commit is contained in:
Marco Arnulfo
2026-10-23 16:21:56 +02:00
parent 16c30830ef
commit c250150dbc
4 changed files with 124 additions and 5 deletions
+7
View File
@@ -262,6 +262,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "フレームアスペクト比:{}"),
("Frame Blending: {}", "フレームブレンド:{}"),
("Frame Rate:", "フレームレート:"),
("Resolution:", "解像度:"),
("Frame Render Time {}ms", "フレームのレンダリング時間 {}ms"),
("Frame Target:", "フレームターゲット:"),
("Freeze", "フリーズ"),
@@ -997,6 +998,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "帧长宽比:{}"),
("Frame Blending: {}", "帧混合:{}"),
("Frame Rate:", "帧速率:"),
("Resolution:", "分辨率:"),
("Frame Render Time {}ms", "帧渲染时间 {} 毫秒"),
("Frame Target:", "帧目标:"),
("Freeze", "冻结"),
@@ -1737,6 +1739,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "影格長寬比:{}"),
("Frame Blending: {}", "影格混合:{}"),
("Frame Rate:", "影格速率:"),
("Resolution:", "解析度:"),
("Frame Render Time {}ms", "影格算圖時間 {} 毫秒"),
("Frame Target:", "幀目標:"),
("Freeze", "凍結"),
@@ -2480,6 +2483,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "Пропорції кадру: {}"),
("Frame Blending: {}", "Змішування кадрів: {}"),
("Frame Rate:", "Частота кадрів:"),
("Resolution:", "Роздільність:"),
("Frame Render Time {}ms", "Час рендерингу кадру {} мс"),
("Frame Target:", "Цільовий фрейм:"),
("Freeze", "Заморозити"),
@@ -3040,6 +3044,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"),
@@ -3498,6 +3503,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"),
@@ -4061,6 +4067,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"),
+45 -3
View File
@@ -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<String>,
@@ -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`: every n-th pixel, a partial one counted (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) {
let n = u32::from(res.factor().unwrap_or(1).max(1));
(w.div_ceil(n), h.div_ceil(n))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_size_follows_the_resolution() {
assert_eq!(frame_at(1920, 1081, Resolution::Half), (960, 541), "an odd side keeps its last pixel");
assert_eq!(frame_at(1920, 1080, Resolution::Third), (640, 360));
assert_eq!(frame_at(1920, 1080, Resolution::Custom(7)), (275, 155));
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)");
+68
View File
@@ -102,6 +102,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;
@@ -408,6 +409,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());
}
@@ -420,6 +426,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<Value> {
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) {
@@ -827,6 +842,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();
+4 -2
View File
@@ -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