diff --git a/.gitignore b/.gitignore index 15532a00..4a79323c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,3 @@ target/ /log .DS_Store .claude/worktrees/ -/out-i18n/ diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 3aa5cf0f..04a9d95e 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -16,8 +16,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod convert; -mod dpx; pub mod downmix; +mod dpx; pub mod exr_channels; mod gif_anim; mod layered; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 0e7a838c..98c70710 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -626,7 +626,8 @@ impl Inner { /// and log it the first time. I/O errors (missing or unreadable file) are not decoder /// problems and are left to the missing-footage handling. fn note_problem(&self, path: &Arc, codec: &str, e: &MediaError) { - if matches!(e, MediaError::Io(_)) { + // A decode called off for a newer frame (#329) says nothing about the file either. + if matches!(e, MediaError::Io(_) | MediaError::Cancelled) { return; } let reason = describe_problem(codec, e); @@ -922,8 +923,10 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re /// The one-line reason a movie shows no picture: its `codec` (the probe's, e.g. "H.264 (High /// 4:2:2)") and what the decoder said, without the error wrappers it passed through. fn describe_problem(codec: &str, e: &MediaError) -> String { - let (MediaError::Io(s) | MediaError::Unsupported(s) | MediaError::Decode(s)) = e; - let mut detail = s.as_str(); + let mut detail = match e { + MediaError::Io(s) | MediaError::Unsupported(s) | MediaError::Decode(s) => s.as_str(), + MediaError::Cancelled => "the decode was called off", + }; let mut unsupported = matches!(e, MediaError::Unsupported(_)); while let Some(rest) = ["decode error: ", "unsupported media: ", "unsupported: ", "I/O error: "].iter().find_map(|p| detail.strip_prefix(p)) { unsupported |= detail.starts_with("unsupported"); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8221de22..087e6dd1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -123,6 +123,13 @@ pub trait FootageSource: Send + Sync { fn called_off(&self) -> bool { false } + /// Why `footage` shows no picture although its file is there: a read of it failed because + /// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged + /// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it + /// decodes, and for sources that don't track failures. Stays set until the file is forgotten. + fn problem(&self, _footage: &Footage) -> Option { + None + } } /// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` @@ -185,12 +192,11 @@ impl FootageSource for CancellableFootage<'_> { fn called_off(&self) -> bool { self.cancel.load(Ordering::Relaxed) || self.inner.called_off() } - /// Why `footage` shows no picture although its file is there: a read of it failed because - /// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged - /// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it - /// decodes, and for sources that don't track failures. Stays set until the file is forgotten. - fn problem(&self, _footage: &Footage) -> Option { - None + fn problem(&self, footage: &Footage) -> Option { + self.inner.problem(footage) + } + fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { + self.read(|| self.inner.frame_with(item, footage, t, size, float)) } } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 1e578b74..547c02d4 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -118,6 +118,7 @@ fn range_start_end_and_offset_are_capped() { set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0)); set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(100.0)); assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]); + let l = &mut p.comp_mut(cid).unwrap().layers[0]; set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(0.0)); assert_eq!(selection(&p, cid, 0.0, a), vec![1.0, 1.0, 0.0, 0.0]); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 94a171fa..2b1217b6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4004,7 +4004,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("file.openDemoProject", "Open Demo Project", "Abrir projeto de demonstração"), ]; -// >>> generated i18n catalogs (out-i18n/assemble.py) +// >>> Russian, French and German menu catalogs /// Russian (addition): the same (command, English label) keys as `JAPANESE`, one /// entry per fixed menu entry, in the same order. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25b2fc23..b10b0e31 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -777,7 +777,6 @@ fn toggle(slot: &mut bool, p: &Value) -> Value { json!(*slot) } - /// Perform a frontend command (from `Event::Frontend`). pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Value) -> Result { let now = ctx.input(|i| i.time); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index b2f83c8e..dab3f8a7 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1126,12 +1126,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.min.y + 4.0), vec2(40.0, 18.0)); if let Some(mut value) = editing.as_ref().cloned() { let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); - let response = ui.put( - name_rect, - egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0)), - ); + let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); - let escape = response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)); + // Escape cancels. egui drops the field's focus on Escape in the same frame, so the + // key is checked on the focus being lost as well as held. + let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); if escape || commit { ctx.data_mut(|d| d.remove::(rename_id)); @@ -1201,7 +1200,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { item(ui, "Copy", true, "effect.copy", json!({"layer": lid, "effect": g.uid})); item(ui, "Paste", has_clip, "effect.paste", json!({"layers": [lid]})); item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid})); - if ui.button("Rename").clicked() { + if ui.button(crate::i18n::tr("Rename")).clicked() { ctx.data_mut(|d| d.insert_temp(rename_id, g.name.clone())); ctx.memory_mut(|m| m.request_focus(rename_id.with("field"))); ui.close(); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index c0ebf013..e5d8e4e0 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -122,9 +122,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Bottom bar: magnification and View menus. p.rect_filled(bar, 0.0, t.panel_bg); let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0)); - if let Some((z, up)) = - super::viewer_tools::magnification_menu(app, ui, mr, "layer-mag", "layerPanel.magnification", "layerMagnification", zoom as f32, app.ui.layer_zoom, false) - { + if let Some((z, up)) = super::viewer_tools::magnification_menu( + app, + ui, + mr, + "layer-mag", + "layerPanel.magnification", + "layerMagnification", + zoom as f32, + app.ui.layer_zoom, + false, + ) { // The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %. app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z }; app.ui.layer_pan = [0.0, 0.0]; diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index e4079981..0bd5f526 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -673,10 +673,29 @@ fn effect_control_name_can_be_renamed_from_its_header() { h.step(); h.key_press(egui::Key::Enter); h.run_steps(3); - let current_name = |h: &Harness<'_, EffectcraftApp>| { - layer(&h.state().session, x.small).effects().unwrap().groups().find(|g| g.uid == x.point_fx).unwrap().name.clone() - }; + let current_name = + |h: &Harness<'_, EffectcraftApp>| layer(&h.state().session, x.small).effects().unwrap().groups().find(|g| g.uid == x.point_fx).unwrap().name.clone(); assert_eq!(current_name(&h), "Speed"); h.state_mut().session.execute("edit.undo", json!({})).unwrap(); assert_eq!(current_name(&h), "Point Control"); } + +#[test] +fn effect_rename_escape_keeps_the_name() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let point = rect_of(&h, &format!("effectControls.effect.{}.name", x.point_fx)).center(); + click(&mut h, point); + click(&mut h, point); + h.run_steps(3); + assert!(ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx))); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Speed".into())); + h.step(); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let name = layer(&h.state().session, x.small).effects().unwrap().groups().find(|g| g.uid == x.point_fx).unwrap().name.clone(); + assert_eq!(name, "Point Control"); + assert!(!ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx))); +} diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/ui_ram_preview.rs index 5459be8c..930c43eb 100644 --- a/crates/ui-egui/tests/ui_ram_preview.rs +++ b/crates/ui-egui/tests/ui_ram_preview.rs @@ -136,8 +136,11 @@ fn cached_frames_are_counted_at_one_third_resolution() { let mut s = Session::default(); let c = s.execute("comp.new", json!({"name": "Third", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap(); + // Each composition keeps its own Resolution (#417). + if let Some(comp) = std::sync::Arc::make_mut(&mut s.project).comp_mut(ItemId(c)) { + comp.resolution = res; + } let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); - h.state_mut().ui.viewer.res = res; settle(&mut h); let series = h.state().shown_series(ItemId(c)); assert_eq!(series.scale, (res.scale(1.0, 1.0) * 1000.0).round() as u32, "{res:?}");