From 1c3522be4a4694e4e13c84b6a13daffeaf3a7611 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 18:55:49 -0600 Subject: [PATCH 1/5] #382: test that audio decoding at a zero mix rate does not divide by zero Resampling footage audio divided by the requested mix rate, so a rate of 0 panicked. The test decodes a 44.1 kHz WAV at rate 0 and expects a read of the requested length. Without the guard it panics with 'attempt to divide by zero'. --- crates/media/src/pool.rs | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9a8ec5c2..d363143d 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -1030,6 +1030,48 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + // Regression test for #382 (#274): resampling footage audio divided by the requested mix + // rate, so a rate of 0 panicked (division by zero); the read must come back instead. + #[test] + fn decoding_audio_at_a_zero_mix_rate_does_not_divide_by_zero() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-zero-rate"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // One second of a 440 Hz tone, 44.1 kHz mono 16-bit PCM: not the 48 kHz mix rate. + let native = 44_100u32; + let pcm: Vec = + (0..native).flat_map(|n| (((n as f32 * 440.0 * std::f32::consts::TAU / native as f32).sin() * 8000.0) as i16).to_le_bytes()).collect(); + let mut wav = b"RIFF".to_vec(); + wav.extend((36 + pcm.len() as u32).to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend([1u16, 1].iter().flat_map(|v| v.to_le_bytes())); + wav.extend(native.to_le_bytes()); + wav.extend((native * 2).to_le_bytes()); + wav.extend([2u16, 16].iter().flat_map(|v| v.to_le_bytes())); + wav.extend(b"data"); + wav.extend((pcm.len() as u32).to_le_bytes()); + wav.extend(&pcm); + let path = dir.join("tone.wav"); + std::fs::write(&path, &wav).unwrap(); + let footage = Footage { + path: path.to_string_lossy().into(), + kind: FootageKind::Audio, + duration: Tick::from_seconds_f64(1.0), + has_audio: true, + ..Default::default() + }; + let pool = MediaPool::new(); + // The file really is read, and resampled, at a normal rate... + let tone = pool.decode_audio(&footage, Tick::from_seconds_f64(0.1), 1024, 48_000); + assert!(tone.iter().any(|v| v.abs() > 0.05), "the tone is audible at 48 kHz"); + // ...and a mix rate of 0 (which `audio_samples` itself turns away) returns a read of the + // requested length instead of dividing by it. + let degenerate = pool.decode_audio(&footage, Tick::from_seconds_f64(0.1), 64, 0); + assert_eq!(degenerate.len(), 128); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn frame_index_loops_and_clamps() { let r = FrameRate::FPS_30; From d87a6940f448ab871404e646daa323123f525fe8 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 18:55:49 -0600 Subject: [PATCH 2/5] #979: test that layer context menus offer Create Shapes from Vector Layer Right-clicking vector footage in the Timeline or the Composition viewer offered no vector conversion. The test imports an SVG and checks the item is absent for a solid, present on the SVG layer in both menus, and converts it. Without the fix the menu item is missing. --- crates/ui-egui/tests/ui_workflow_selection.rs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 6e264dd3..eae2bd54 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -637,3 +637,66 @@ fn timeline_context_menu_creates_shapes_and_masks_from_text() { assert!(h.query_by_label("Create Shapes from Text").is_none(), "no text conversion for a solid"); assert!(h.query_by_label("Create Masks from Text").is_none(), "no text conversion for a solid"); } + +// Regression test for #979 (#958): right-clicking a vector footage layer (SVG) in the Timeline +// or the Composition viewer offered no Create Shapes from Vector Layer; raster layers must not. +#[test] +fn layer_context_menus_offer_create_shapes_from_vector_layer_for_vector_footage() { + use effectcraft_engine::project::LayerSource; + let dir = std::env::temp_dir().join(format!("ec-ui-vector-menu-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let svg = dir.join("logo.svg"); + std::fs::write( + &svg, + r##""##, + ) + .unwrap(); + let mut s = effectcraft_host::session(); + let item = s.execute("file.import", json!({"paths": [svg.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name":"Vec","width":320,"height":180,"duration":4})).unwrap(); + let logo = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + let plate = s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + let row = |h: &Harness<'_, EffectcraftApp>, id: u64| { + let r = rect(h, &format!("timeline.layer.{id}.row")); + pos2(r.min.x + 210.0, r.center().y) + }; + let label = "Create Shapes from Vector Layer"; + let shapes = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); + // A raster-less solid does not offer the conversion. + h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); + h.run_steps(3); + let at = row(&h, plate); + right_click(&mut h, at); + assert!(h.query_by_label(label).is_none(), "no vector conversion for a solid"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + // The Timeline row of the SVG layer offers it, and it converts the layer. + h.state_mut().session.execute("layer.select", json!({"layers": [logo]})).unwrap(); + h.run_steps(3); + let at = row(&h, logo); + right_click(&mut h, at); + h.get_by_label(label).click(); + h.run_steps(3); + assert_eq!(shapes(&h), 1, "a shape layer was created from the SVG layer"); + // The Composition viewer's menu offers it for the SVG layer under the pointer too. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.state_mut().session.execute("prop.set", json!({"layer": plate, "path": "transform/position", "value": [40.0, 40.0]})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [logo]})).unwrap(); + h.run_steps(3); + let comp = rect(&h, "viewer.comp"); + right_click(&mut h, comp.center()); + assert!(h.query_by_label(label).is_some(), "the viewer's context menu offers the vector conversion"); + let _ = std::fs::remove_dir_all(&dir); +} From 841ff7bc4b310dbccb922e67efc6acab46b0dbd8 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 18:55:49 -0600 Subject: [PATCH 3/5] #354: test that the panel focus outline is drawn over the panel bodies The focused panel's outline was painted before the panel body, so body fills hid it. The test finds the outline in the painted shapes and checks no filled rect painted after it reaches its one-pixel ring. Without the fix a body fill overlaps the ring. --- crates/ui-egui/tests/ui_panel_focus.rs | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/ui-egui/tests/ui_panel_focus.rs b/crates/ui-egui/tests/ui_panel_focus.rs index 5d3302b1..8fe2c881 100644 --- a/crates/ui-egui/tests/ui_panel_focus.rs +++ b/crates/ui-egui/tests/ui_panel_focus.rs @@ -187,3 +187,47 @@ fn double_clicking_an_effect_in_the_timeline_opens_effect_controls() { assert_eq!(st.selected_props.iter().map(|(l, u)| (l.0, *u)).collect::>(), vec![(red, uid)], "with the effect selected"); assert!(!h.state().ui.timeline.open_groups.contains(&uid), "the effect did not twirl open"); } + +// Regression test for #354: the focused panel's blue outline was painted before the panel body, +// so the body's background and widgets painted over it. It must be drawn after every panel body. +#[test] +fn focused_panel_outline_is_painted_over_the_panel_bodies() { + fn flatten<'a>(shape: &'a egui::Shape, clip: egui::Rect, out: &mut Vec<(egui::Rect, &'a egui::epaint::RectShape)>) { + match shape { + egui::Shape::Rect(r) => out.push((clip, r)), + egui::Shape::Vec(v) => v.iter().for_each(|s| flatten(s, clip, out)), + _ => {} + } + } + let (mut h, _, _) = harness(); + for panel in [PanelKind::Timeline, PanelKind::Project] { + h.state_mut().ui.focused = panel; + h.run_steps(3); + let focus = h.state().tokens.focus; + let mut rects = vec![]; + h.output().shapes.iter().for_each(|c| flatten(&c.shape, c.clip_rect, &mut rects)); + // The one-pixel, unfilled outline round the focused panel's frame. + let body = rect(&h, &format!("panel.{}", panel.id())); + let outlines: Vec = rects + .iter() + .enumerate() + .filter(|(_, (_, r))| r.fill.a() == 0 && r.stroke.color == focus && r.stroke.width == 1.0 && r.rect.contains_rect(body)) + .map(|(i, _)| i) + .collect(); + let Some(&at) = outlines.last() else { panic!("{panel:?}: no focus outline painted") }; + let outline = rects[at].1.rect; + // Nothing painted after it reaches the one-pixel ring it draws, where a panel's + // background or rows would hide it. + let ring = [ + egui::Rect::from_min_max(outline.min, egui::pos2(outline.right(), outline.top() + 1.0)), + egui::Rect::from_min_max(egui::pos2(outline.left(), outline.bottom() - 1.0), outline.max), + egui::Rect::from_min_max(outline.min, egui::pos2(outline.left() + 1.0, outline.bottom())), + egui::Rect::from_min_max(egui::pos2(outline.right() - 1.0, outline.top()), outline.max), + ]; + for (clip, r) in &rects[at + 1..] { + let painted = r.rect.intersect(*clip); + let over = ring.iter().any(|edge| edge.intersect(painted).area() > 0.0); + assert!(r.fill.a() == 0 || !over, "{panel:?}: a filled rect {:?} (fill {:?}) is painted over the focus outline {outline:?}", r.rect, r.fill); + } + } +} From 7c1183df56b83cc56a6ef60cf214b4a4232d8679 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 18:55:49 -0600 Subject: [PATCH 4/5] #1497: test that double-clicking an effect property name reveals it in the timeline Double-clicking a property name in Effect Controls did nothing. The test double-clicks the name and checks the Timeline marks the property revealed and shows its row. Without the fix nothing is revealed. --- crates/ui-egui/tests/ui_effect_controls.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index cdf8c703..90f54931 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -778,6 +778,23 @@ fn effect_control_name_can_be_renamed_from_its_header() { assert_eq!(current_name(&h), "Point Control"); } +// Regression test for #1497: double-clicking an effect parameter's name in Effect Controls did +// nothing; it should reveal that property in the Timeline, as After Effects does. +#[test] +fn double_clicking_an_effect_property_name_reveals_it_in_the_timeline() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app); + settle(&mut h); + let row = format!("timeline.prop.{}.name", x.point); + assert!(!ids(&h).contains(&row), "the effect's property is not in the Timeline before the double-click"); + assert!(!h.state().ui.timeline.reveal_props.contains(&x.point)); + let point = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center(); + double_click(&mut h, point); + h.run_steps(4); + assert!(h.state().ui.timeline.reveal_props.contains(&x.point), "the property is marked revealed in the Timeline"); + assert!(ids(&h).contains(&row), "the Timeline shows the property's row"); +} + #[test] fn effect_rename_escape_keeps_the_name() { let (app, x) = app(); From 8fe2f51fb05d8b99b668a65f87af630b9e7ad3d3 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 19:13:31 -0600 Subject: [PATCH 5/5] #979: cover raster footage and a selection elsewhere in the vector-menu test A raster footage layer's Timeline menu now also has to leave out Create Shapes from Vector Layer. The viewer check keeps the solid selected, so the menu has to follow the layer under the pointer rather than the selection. With the viewer's caps merge made a no-op, the test fails at the viewer check. --- crates/ui-egui/tests/ui_workflow_selection.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index eae2bd54..d2777b22 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -651,9 +651,14 @@ fn layer_context_menus_offer_create_shapes_from_vector_layer_for_vector_footage( r##""##, ) .unwrap(); + let png = dir.join("photo.png"); + image::RgbaImage::from_pixel(16, 16, image::Rgba([0, 128, 255, 255])).save(&png).unwrap(); let mut s = effectcraft_host::session(); let item = s.execute("file.import", json!({"paths": [svg.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap(); + let raster = s.execute("file.import", json!({"paths": [png.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap(); s.execute("comp.new", json!({"name":"Vec","width":320,"height":180,"duration":4})).unwrap(); + // The raster layer sits below the SVG layer, so the viewer click below lands on the SVG. + let photo = s.execute("layer.addItem", json!({"item": raster})).unwrap()["layer"].as_u64().unwrap(); let logo = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); let plate = s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap()["layer"].as_u64().unwrap(); s.execute("edit.deselectAll", json!({})).unwrap(); @@ -682,6 +687,14 @@ fn layer_context_menus_offer_create_shapes_from_vector_layer_for_vector_footage( assert!(h.query_by_label(label).is_none(), "no vector conversion for a solid"); h.key_press(egui::Key::Escape); h.run_steps(3); + // Nor does raster footage. + h.state_mut().session.execute("layer.select", json!({"layers": [photo]})).unwrap(); + h.run_steps(3); + let at = row(&h, photo); + right_click(&mut h, at); + assert!(h.query_by_label(label).is_none(), "no vector conversion for raster footage"); + h.key_press(egui::Key::Escape); + h.run_steps(3); // The Timeline row of the SVG layer offers it, and it converts the layer. h.state_mut().session.execute("layer.select", json!({"layers": [logo]})).unwrap(); h.run_steps(3); @@ -693,7 +706,8 @@ fn layer_context_menus_offer_create_shapes_from_vector_layer_for_vector_footage( // The Composition viewer's menu offers it for the SVG layer under the pointer too. h.state_mut().session.execute("edit.undo", json!({})).unwrap(); h.state_mut().session.execute("prop.set", json!({"layer": plate, "path": "transform/position", "value": [40.0, 40.0]})).unwrap(); - h.state_mut().session.execute("layer.select", json!({"layers": [logo]})).unwrap(); + // The solid stays selected: the menu must follow the layer under the pointer, not the selection. + h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); h.run_steps(3); let comp = rect(&h, "viewer.comp"); right_click(&mut h, comp.center());