Merge pull request #391 from txiki739/fix/export-menu-paths

#382: File > Export entries work from the menus
This commit is contained in:
Brandon Thomas 2026-10-09 08:17:12 -04:00 committed by GitHub
commit 6a240ca701
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 193 additions and 1 deletions

View File

@ -172,6 +172,17 @@ const SCREENSHOT_TIMEOUT_S: f64 = 10.0;
/// timeline zoom animation finished) before capturing what is there.
const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0;
/// File ▸ Export entries that run from the menus through the save panel: command, filter label,
/// extension.
pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 6] = [
("file.exportEdl", "EDL", "edl"),
("file.exportFcp7Xml", "Final Cut Pro XML", "xml"),
("file.exportFcpxml", "FCPXML", "fcpxml"),
("file.exportOtio", "OpenTimelineIO", "otio"),
("file.exportAle", "Avid Log Exchange", "ale"),
("file.exportSelectionProject", "FilmCraft Project", "fcproj"),
];
pub struct FilmcraftApp {
pub session: Session,
pub ui: UiState,
@ -1007,10 +1018,40 @@ impl FilmcraftApp {
p["path"] = json!(path);
self.session.execute("captions.export", p).map_err(|e| e.to_string())
}
_ => Err(format!("no dialog for {id}")),
_ => match EXPORT_SAVE_DIALOGS.iter().find(|(c, ..)| *c == id) {
Some(&(_, filter, ext)) => self.export_save_dialog(id, filter, ext, params),
None => Err(format!("no dialog for {id}")),
},
}
}
/// File ▸ Export ▸ EDL…, Final Cut Pro XML… etc. from the menus (#382): ask where to save, then
/// run the command with that `path` and the rest of `params`.
fn export_save_dialog(&mut self, id: &str, filter: &str, ext: &str, params: &Value) -> Result<Value, String> {
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&self.session))?;
let Some(pick) = self.hooks.pick_save_as.as_mut() else {
return Err("no save dialog available: run the command with a `path`".into());
};
// Timelines are named after the sequence; a selection must not suggest the open project's file.
let sequence = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone());
let stem = match id {
"file.exportAle" => self.session.project.name.clone(),
"file.exportSelectionProject" => format!("{} selection", self.session.project.name),
_ => sequence.unwrap_or_else(|| self.session.project.name.clone()),
};
let stem: String = stem.chars().map(|c| if matches!(c, '/' | '\\') || c.is_control() { '-' } else { c }).collect();
let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem };
let Some(path) = pick(filter, &[ext], &format!("{stem}.{ext}")) else { return Ok(Value::Null) };
let mut p = params.as_object().cloned().unwrap_or_default();
p.insert("path".into(), json!(path));
let r = self.session.execute(id, Value::Object(p)).map_err(|e| e.to_string());
self.ui.status = match &r {
Ok(_) => format!("Exported {path}"),
Err(e) => e.clone(),
};
r
}
/// Import dropped files.
fn handle_drops(&mut self, ctx: &egui::Context) {
let dropped = ctx.input(|i| i.raw.dropped_files.clone());

View File

@ -310,6 +310,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
return crate::panels::interchange_export::open(app, ctx, id);
}
// File ▸ Export ▸ Media… opens the Export mode, like ⌘M (#382); with a path it exports directly.
"file.exportMedia" if params.get("path").is_none() => {
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
app.ui.mode = Mode::Export;
return Ok(json!({"mode": "export"}));
}
// Audio Gain from the menu or G opens the dialog; with params it applies directly.
"clip.audioGain" if params.as_object().is_none_or(|m| m.is_empty()) => {
filmcraft_engine::find_command("clip.audioGain").map_or(Ok(()), |c| (c.enabled)(&app.session))?;
@ -388,6 +394,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
|| (id == "file.save" && params.get("path").is_none() && app.session.path.is_none())
|| (matches!(id, "captions.import" | "captions.export") && params.get("path").is_none())
|| (id == "graphics.newFromFile" && params.get("path").is_none())
|| (crate::EXPORT_SAVE_DIALOGS.iter().any(|(c, ..)| *c == id) && params.get("path").is_none())
{
return app.file_dialog(id, &params);
}

View File

@ -0,0 +1,144 @@
//! Headless UI tests of File ▸ Export from the menus (#382): a menu entry carries no parameters, so
//! the entries whose command needs a `path` ask the host's save panel for it, and Media… opens the
//! Export mode like ⌘M, instead of failing with a missing `path`.
use std::sync::mpsc::{Sender, channel};
use std::sync::{Arc, Mutex};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use filmcraft_ui_egui::state::Mode;
use serde_json::{Value, json};
/// What the save panel was asked: filter label, extensions, suggested name.
type Asked = Arc<Mutex<Vec<(String, Vec<String>, String)>>>;
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
/// `save_to: None` is a cancelled save panel.
fn new(session: Session, save_to: Option<std::path::PathBuf>, asked: Asked) -> Self {
let (tx, rx) = channel();
let mut app = FilmcraftApp::new(session).with_control(rx);
app.hooks.pick_save_as = Some(Box::new(move |filter, exts, suggested| {
asked.lock().unwrap().push((filter.to_string(), exts.iter().map(|e| e.to_string()).collect(), suggested.to_string()));
save_to.as_ref().map(|d| d.join(suggested).to_string_lossy().into_owned())
}));
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let v = self.call(method, params.clone());
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
v["result"].clone()
}
}
/// The demo project (sequences, clips) with one clip selected in the Project panel.
fn session() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let clip = s.project.items.iter().find(|(_, it)| it.name.ends_with(".mp4")).map(|(id, _)| id.0).unwrap();
s.execute("project.select", json!({"items": [clip]})).unwrap();
s
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("fc-export-menu-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Every File ▸ Export entry whose command needs a path, and the extension it saves.
const SAVED: [(&str, &str); 6] = [
("file.exportEdl", "edl"),
("file.exportFcp7Xml", "xml"),
("file.exportFcpxml", "fcpxml"),
("file.exportOtio", "otio"),
("file.exportAle", "ale"),
("file.exportSelectionProject", "fcproj"),
];
#[test]
fn file_export_entries_ask_for_the_destination() {
let dir = tmp("save");
let asked = Asked::default();
let mut d = Driver::new(session(), Some(dir.clone()), asked.clone());
let menu = d.ok("ui.menu.list", json!({}));
for (id, ext) in SAVED {
let entry = menu.as_array().unwrap().iter().find(|i| i["id"] == id).unwrap_or_else(|| panic!("{id} missing from the menus"));
assert_eq!(entry["path"], json!(["File", "Export"]), "{id}");
let r = d.ok("ui.menu.invoke", json!({"id": id}));
d.frames(2);
let (_, exts, suggested) = asked.lock().unwrap().last().cloned().unwrap_or_else(|| panic!("{id} did not ask where to save"));
assert_eq!(exts, [ext], "{id}");
assert!(suggested.ends_with(&format!(".{ext}")) && suggested.len() > ext.len() + 1, "{id}: {suggested}");
let written = dir.join(&suggested);
assert!(written.metadata().is_ok_and(|m| m.len() > 0), "{id} wrote nothing at {} ({r})", written.display());
}
assert_eq!(asked.lock().unwrap().len(), SAVED.len());
}
#[test]
fn cancelling_the_save_panel_writes_nothing_and_reports_no_error() {
let asked = Asked::default();
let mut d = Driver::new(session(), None, asked.clone());
for (id, _) in SAVED {
assert_eq!(d.ok("ui.menu.invoke", json!({"id": id})), Value::Null, "{id}");
}
assert_eq!(asked.lock().unwrap().len(), SAVED.len());
assert!(!d.harness.state().ui.status.contains("path"), "{}", d.harness.state().ui.status);
}
#[test]
fn media_from_the_menu_opens_the_export_mode() {
let mut d = Driver::new(session(), None, Asked::default());
assert_eq!(d.harness.state().ui.mode, Mode::Edit);
d.ok("ui.menu.invoke", json!({"id": "file.exportMedia"}));
d.frames(2);
assert_eq!(d.harness.state().ui.mode, Mode::Export);
// with a path it still exports directly, as scripts and the CLI use it
let out = tmp("media").join("direct.wav");
let r = d.ok("ui.menu.invoke", json!({"id": "file.exportMedia", "params": {"path": out.to_string_lossy(), "format": "wav", "wait": true}}));
assert!(out.metadata().is_ok_and(|m| m.len() > 0), "{r}");
}
#[test]
fn file_export_entries_need_an_open_sequence() {
let mut d = Driver::new(Session::default(), Some(tmp("noseq")), Asked::default());
for id in ["file.exportMedia", "file.exportEdl", "file.exportFcp7Xml", "file.exportFcpxml", "file.exportOtio"] {
assert_eq!(d.call("ui.menu.invoke", json!({"id": id}))["ok"], json!(false), "{id}");
assert_eq!(d.harness.state().ui.mode, Mode::Edit, "{id}");
}
}