Integration fixes for the community batch

- Footage sources: the cancellable wrapper (#784) forwards frame_with (#752 float curves)
  and problem (#887); problem's default lives on the trait.
- Media: a called-off decode (#784) is not a footage problem (#887).
- Effect Controls rename (#953): Escape cancels (egui drops focus on Escape the same
  frame), the Rename item is translated; regression test.
- i18n (#873): drop the /out-i18n/ ignore rule and the note about an uncommitted script.
- Tests: text range selector caps (#926) re-borrow the layer; the RAM preview test (#793)
  sets the comp's own Resolution (#462).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-12 04:27:59 -04:00
1 parent ac37493026
commit 6baff9f5fe
11 files changed
+63 -26

No files matched your search

-1
View File
@@ -5,4 +5,3 @@ target/
/log
.DS_Store
.claude/worktrees/
/out-i18n/
+1 -1
View File
@@ -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;
+6 -3
View File
@@ -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<str>, 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");
+12 -6
View File
@@ -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<String> {
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<String> {
None
fn problem(&self, footage: &Footage) -> Option<String> {
self.inner.problem(footage)
}
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
self.read(|| self.inner.frame_with(item, footage, t, size, float))
}
}
+1
View File
@@ -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]);
}
+1 -1
View File
@@ -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.
-1
View File
@@ -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<Value, String> {
let now = ctx.input(|i| i.time);
+5 -6
View File
@@ -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::<String>(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();
+11 -3
View File
@@ -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];
+22 -3
View File
@@ -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)));
}
+4 -1
View File
@@ -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:?}");