Merge pull request #447 from David-T-Campos/fix/footage-drag-drop

# Conflicts:
#	apps/effectcraft/src/main.rs
This commit is contained in:
Claude 2026-10-10 12:54:44 +00:00
commit 63acbb1eb0
No known key found for this signature in database
5 changed files with 262 additions and 39 deletions

View File

@ -285,6 +285,20 @@ into a folder of your choice: `msiexec /i effectcraft-<ver>-windows-x64.msi /qn
| Fedora/RHEL/openSUSE | `effectcraft-<ver>-linux-x86_64.rpm` | `effectcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `effectcraft-<ver>-linux-x86_64.tar.gz` | `effectcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
**File drag-and-drop on Linux:** The current eframe/winit 0.30 native Wayland backend does not
deliver file-hover or file-drop events. On Hyprland and other native Wayland sessions, use
**File ▸ Import ▸ File…** (which accepts multiple files), or start EffectCraft via XWayland if
XWayland is installed and `DISPLAY` is set:
```sh
env -u WAYLAND_DISPLAY effectcraft
```
When the backend delivers events (X11/XWayland, Windows and macOS), dropping media anywhere
in the window imports it, and a drop-target hint appears while hovering. On native Wayland the
app cannot detect an attempt that generates no event; it shows a startup notice with the
File ▸ Import fallback instead.
### FreeBSD
| Build | File |

View File

@ -48,6 +48,13 @@ fn is_project_file(path: &str) -> bool {
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
}
/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.
#[cfg(target_os = "linux")]
fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {
(wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))
.then_some("Native Wayland file drops are unavailable. Use File > Import > File... or run via XWayland (see docs/footage.md).")
}
/// The command line.
#[derive(Debug, Default, PartialEq)]
struct Args {
@ -188,6 +195,14 @@ fn main() -> eframe::Result {
if let Some(r) = recovery {
app.offer_recovery(r);
}
#[cfg(target_os = "linux")]
if let Some(note) = wayland_drop_notice(
std::env::var_os("WAYLAND_DISPLAY").as_deref(),
std::env::var_os("WINIT_UNIX_BACKEND").as_deref(),
) && app.ui.status.is_empty()
{
app.ui.status = note.into();
}
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}));
@ -366,6 +381,17 @@ mod tests {
parse_args(args.iter().map(|a| a.to_string()), None)
}
#[cfg(target_os = "linux")]
#[test]
fn wayland_drop_notice_recommends_import_or_xwayland() {
use std::ffi::OsStr;
let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();
assert!(warning.contains("File > Import") && warning.contains("XWayland"));
assert!(super::wayland_drop_notice(None, None).is_none());
assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());
assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());
}
/// The window's device never asks for more than the adapter offers (#198).
#[test]
fn device_limits_fit_the_adapter() {

View File

@ -1508,6 +1508,8 @@ impl EffectcraftApp {
header::show(self, ui, header);
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
self.dock_area(ui, body);
#[cfg(not(target_arch = "wasm32"))]
prefs_live::dropped_files(self, ui, full);
panels::precomp::mini_flowchart(self, &ctx);
panels::home::capture_thumbnail(self);
panels::dialogs::show(self, &ctx);

View File

@ -11,41 +11,122 @@ use crate::frames::FrameKey;
/// Run once per frame, before the panels draw.
pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
dropped_files(app, ctx);
memory_watch(app, ctx);
home_on_open(app, ctx);
video_preview(app, ctx);
}
/// Files dropped on the window are imported (layered files as Default Drag Import As says) in
/// the background, with the Importing card showing what is read (#270). Dropped on the
/// Composition viewer, they also become layers centred there, as in After Effects (#85), when the
/// platform reports where the pointer is during file drags.
fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) {
let paths: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| std::path::Path::new(p).is_absolute()).collect());
if paths.is_empty() {
/// Import native file drops through the same command as the file picker. Run after the dock
/// layout to use the current viewer rectangle for drops that also add layers.
#[cfg(not(target_arch = "wasm32"))]
pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
use egui::{Align2, Stroke, StrokeKind, vec2};
let ctx = ui.ctx().clone();
let hovered = ctx.input(|i| i.raw.hovered_files.len());
if hovered != 0 {
let label = if hovered == 1 {
"Drop file to import".to_string()
} else {
format!("Drop {hovered} files to import")
};
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("native-file-drop"),
));
painter.rect_stroke(
bounds.shrink(8.0),
6.0,
Stroke::new(2.0, app.tokens.accent),
StrokeKind::Inside,
);
let hint = Rect::from_center_size(bounds.center(), vec2(300.0, 46.0));
painter.rect_filled(hint, 6.0, app.tokens.panel_bg);
painter.text(
hint.center(),
Align2::CENTER_CENTER,
&label,
crate::theme::Tokens::medium(14.0),
app.tokens.text,
);
app.auto.add("file.dropTarget", hint, &label);
}
let (paths, skipped) = ctx.input(|i| {
let mut paths = Vec::new();
let mut skipped = 0;
for file in &i.raw.dropped_files {
if file.path().is_absolute() {
paths.push(file.path().to_string_lossy().into_owned());
} else {
skipped += 1;
}
}
(paths, skipped)
});
if paths.is_empty() && skipped == 0 {
return;
}
let mut errors = Vec::new();
if skipped != 0 {
errors.push(format!(
"Skipped {skipped} dropped file(s) without a local path. Use File > Import > File..."
));
}
let (proj, rest): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| {
let l = p.to_ascii_lowercase();
l.ends_with(".ecproj") || l.ends_with(".ecprojx")
});
if let Some(p) = proj.first()
&& let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p}))
{
app.ui.status = e;
if proj.len() > 1 {
errors.push(format!(
"Only one project can be opened per drop; skipped {} more",
proj.len() - 1
));
}
if let Some(p) = proj.first()
&& let Err(e) = crate::menus::invoke(app, &ctx, "file.open", json!({"path": p}))
{
errors.push(format!("Cannot open dropped project {p}: {e}"));
}
if !rest.is_empty() {
let viewer = app.auto.find("viewer.area").map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])));
let at =
ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(ctx, p));
let params =
json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), "addToComp": at.is_some(), "position": at, "background": true});
if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) {
app.ui.status = e;
let viewer = app.auto.elements.iter().find(|e| e.id == "viewer.area").map(|e| {
Rect::from_min_size(
egui::pos2(e.rect[0], e.rect[1]),
egui::vec2(e.rect[2], e.rect[3]),
)
});
let at = if proj.is_empty() {
ctx.input(|i| i.pointer.hover_pos())
.filter(|p| viewer.is_some_and(|v| v.contains(*p)))
.and_then(|p| crate::panels::viewer::screen_to_comp(&ctx, p))
} else {
None
};
let params = json!({
"paths": rest,
"drag": true,
"importAs": app.session.prefs.drag_import_as(),
"addToComp": at.is_some(),
"position": at,
"background": true
});
if let Err(e) = crate::menus::invoke(app, &ctx, "file.import", params) {
errors.push(format!("Cannot import dropped files: {e}"));
}
}
if !errors.is_empty() {
app.ui.status = errors.join("; ");
}
}
/// Re-read the system's free memory every 30 s (cache budgets follow it; on Windows each
@ -110,10 +191,21 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) {
let opts = crate::panels::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let t = match prev {
Some((_, mut t)) => {
t.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts);
t.set(
crate::frames::fit_texture(
(*img).clone(),
crate::frames::max_texture_side(ctx),
),
opts,
);
t
}
None => crate::frames::load_fitted(ctx, "video-preview", (*img).clone(), opts),
None => crate::frames::load_fitted(
ctx,
"video-preview",
(*img).clone(),
opts,
),
};
ctx.data_mut(|d| d.insert_temp(tex_id, (k, t.clone())));
Some(t)
@ -122,27 +214,57 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) {
},
(_, _, prev) => prev.map(|p| p.1),
};
let title =
format!("Video Preview — {}", app.session.active_comp_id().and_then(|c| app.session.project.item(c)).map(|i| i.name.clone()).unwrap_or_default());
let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([960.0, 540.0]);
let title = format!(
"Video Preview — {}",
app.session
.active_comp_id()
.and_then(|c| app.session.project.item(c))
.map(|i| i.name.clone())
.unwrap_or_default()
);
let mut builder = egui::ViewportBuilder::default()
.with_title(title)
.with_inner_size([960.0, 540.0]);
if v.device == "fullscreen" {
builder = builder.with_fullscreen(true);
}
let mut closed = false;
ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("effectcraft-video-preview"), builder, |vctx, _| {
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(vctx, |ui| {
let area = ui.max_rect();
if let Some(t) = &tex {
let [w, h] = t.size().map(|x| x.max(1) as f32);
let s = (area.width() / w).min(area.height() / h);
let r = Rect::from_center_size(area.center(), egui::vec2(w * s, h * s));
ui.painter().image(t.id(), r, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("effectcraft-video-preview"),
builder,
|vctx, _| {
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(Color32::BLACK))
.show(vctx, |ui| {
let area = ui.max_rect();
if let Some(t) = &tex {
let [w, h] = t.size().map(|x| x.max(1) as f32);
let s = (area.width() / w).min(area.height() / h);
let r = Rect::from_center_size(
area.center(),
egui::vec2(w * s, h * s),
);
ui.painter().image(
t.id(),
r,
Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, 1.0),
),
Color32::WHITE,
);
}
});
if vctx.input(|i| i.viewport().close_requested()) {
closed = true;
}
});
if vctx.input(|i| i.viewport().close_requested()) {
closed = true;
}
});
},
);
if closed && let Err(e) = app.set_pref("video.enableOutput", Value::Bool(false)) {
app.ui.status = e;
}

View File

@ -197,6 +197,9 @@ impl effectcraft_engine::Importer for Gated {
while self.allow.load(Ordering::SeqCst) <= k && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(1));
}
if path.ends_with(".unsupported") {
return Err("unsupported media format".into());
}
Ok(effectcraft_engine::project::Footage { path: path.into(), width: 64, height: 32, has_video: true, ..Default::default() })
}
}
@ -221,6 +224,9 @@ fn dropped_files_show_import_progress_then_the_items_they_made() {
let allow = Arc::new(AtomicUsize::new(1));
h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: allow.clone() }));
let file = |n: &str| std::env::temp_dir().join(n);
// External files import even when dropped over the Timeline rather than the Project panel.
h.event(Event::PointerMoved(rect(&h, "panel.Timeline").center()));
h.step();
for n in ["a.png", "b.png", "c.png"] {
h.input_mut().dropped_files.push(Arc::new(Dropped(file(n))));
}
@ -242,7 +248,9 @@ fn dropped_files_show_import_progress_then_the_items_they_made() {
assert!(h.state().auto.find(&format!("project.item.{last}")).is_some(), "and in view in the Project panel");
assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 3 items"));
// The same file again: imported again, and that one is selected.
// The same file again over the Project panel: imported again and selected.
h.event(Event::PointerMoved(rect(&h, "panel.Project").center()));
h.step();
h.input_mut().dropped_files.push(Arc::new(Dropped(file("a.png"))));
h.run_steps(2);
step_until(&mut h, |h| items(h) == 4);
@ -253,6 +261,57 @@ fn dropped_files_show_import_progress_then_the_items_they_made() {
assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 1 item"));
}
#[test]
fn native_file_hover_shows_drop_target_hint() {
let (mut h, _) = harness();
for name in ["one.mp4", "two.mov"] {
h.input_mut().hovered_files.push(egui::HoveredFile { path: Some(std::env::temp_dir().join(name)), mime: String::new() });
}
h.step();
assert_eq!(h.state().auto.find("file.dropTarget").map(|e| e.label.as_str()), Some("Drop 2 files to import"));
}
#[test]
fn dropped_file_without_local_path_explains_the_fallback() {
let (mut h, _) = harness();
h.input_mut().dropped_files.push(Arc::new(Dropped("relative.mp4".into())));
h.run_steps(2);
assert!(h.state().ui.status.contains("without a local path"), "{}", h.state().ui.status);
assert!(h.state().ui.status.contains("File > Import"));
}
#[test]
fn dropped_file_on_viewer_imports_and_adds_layer_at_pointer() {
let (mut h, _) = harness();
h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) }));
let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap();
h.event(Event::PointerMoved(to));
h.step();
h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join("viewer-drop.mp4"))));
h.run_steps(2);
step_until(&mut h, |h| h.state().session.active_comp().is_some_and(|c| c.layers.iter().any(|l| l.name == "viewer-drop.mp4")));
let comp = h.state().session.active_comp().unwrap();
let layer = comp.layers.iter().find(|l| l.name == "viewer-drop.mp4").unwrap();
let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") };
let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6;
assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}");
}
#[test]
fn an_unsupported_drop_reports_the_error_and_keeps_valid_files() {
let (mut h, _) = harness();
h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) }));
for name in ["valid.mp4", "bad.unsupported"] {
h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join(name))));
}
h.run_steps(2);
step_until(&mut h, |h| h.state().session.job_log.iter().any(|j| j.kind == "import" && j.message.contains("unsupported media format")));
let job = h.state().session.job_log.iter().find(|j| j.kind == "import").unwrap();
assert!(job.message.contains("bad.unsupported: unsupported media format"), "{}", job.message);
assert_eq!(job.result["errors"].as_array().map(Vec::len), Some(1));
assert!(h.state().session.project.items.values().any(|i| i.name == "valid.mp4"));
}
/// The automation id of the Importing card on screen.
fn card_id(h: &Harness<'_, EffectcraftApp>) -> String {
h.state().auto.query("import.job.").first().map(|e| e.id.clone()).unwrap_or_default()