diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ee1d80cd..a51b1e08 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -1065,6 +1065,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; diff --git a/crates/ui-egui/tests/it/ui_effect_controls.rs b/crates/ui-egui/tests/it/ui_effect_controls.rs index cdf8c703..90f54931 100644 --- a/crates/ui-egui/tests/it/ui_effect_controls.rs +++ b/crates/ui-egui/tests/it/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(); diff --git a/crates/ui-egui/tests/it/ui_panel_focus.rs b/crates/ui-egui/tests/it/ui_panel_focus.rs index 5d3302b1..8fe2c881 100644 --- a/crates/ui-egui/tests/it/ui_panel_focus.rs +++ b/crates/ui-egui/tests/it/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); + } + } +} diff --git a/crates/ui-egui/tests/it/ui_workflow_selection.rs b/crates/ui-egui/tests/it/ui_workflow_selection.rs index 6e264dd3..d2777b22 100644 --- a/crates/ui-egui/tests/it/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/it/ui_workflow_selection.rs @@ -637,3 +637,80 @@ 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 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(); + 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); + // 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); + 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(); + // 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()); + assert!(h.query_by_label(label).is_some(), "the viewer's context menu offers the vector conversion"); + let _ = std::fs::remove_dir_all(&dir); +}