mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
This commit is contained in:
@@ -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<u8> =
|
||||
(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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<_>>(), 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<usize> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20"><rect width="40" height="20" fill="#ff0000"/></svg>"##,
|
||||
)
|
||||
.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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user