diff --git a/apps/filmcraft-web/src/import.rs b/apps/filmcraft-web/src/import.rs index 449f123de..e837a3710 100644 --- a/apps/filmcraft-web/src/import.rs +++ b/apps/filmcraft-web/src/import.rs @@ -10,7 +10,7 @@ use std::cell::RefCell; use std::rc::Rc; use filmcraft_engine::Services; -use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::{FilmcraftApp, RelinkHint}; use serde_json::{Value, json}; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -153,12 +153,43 @@ pub fn take_files(files: Vec) { }); } +/// Files a picker returned: imported, or with a relink hint, the first one is registered under +/// `/files/` and the hint's command runs on it (#111). +fn picked(files: Vec, relink: Option) { + let Some(hint) = relink else { return take_files(files) }; + let Some(f) = files.into_iter().next() else { return }; + wasm_bindgen_futures::spawn_local(async move { + let path = fs::register_blob(&f.name(), f.clone().into()); + crate::recovery::keep_media(&path, &f); + let mut params = hint.params; + if !params.is_object() { + params = json!({}); + } + params["path"] = json!(path); + let command = hint.command; + let outcome = on_ui(move |w, _| w.app.session.execute(&command, params).map_err(|e| e.to_string())).await; + crate::post(move |w, _| match outcome { + Ok(v) => { + if let Some(n) = v.get("relinked").and_then(Value::as_array).map(Vec::len) { + w.app.status(format!("Linked {n} clip(s)")); + } + } + Err(e) => w.app.status(e), + }); + }); +} + fn has_fsa_picker() -> bool { web_sys::window().is_some_and(|w| js_sys::Reflect::has(&w, &"showOpenFilePicker".into()).unwrap_or(false)) } /// Let the user pick files: `showOpenFilePicker` when available, else a file input. pub fn pick(exts: &[&str], multiple: bool) { + pick_then(exts, multiple, None); +} + +/// [`pick`], handing the files to [`picked`]: a cancelled picker leaves nothing pending. +fn pick_then(exts: &[&str], multiple: bool, relink: Option) { let accept: Vec = exts.iter().map(|e| format!(".{e}")).collect(); if has_fsa_picker() { let opts = js_sys::Object::new(); @@ -178,7 +209,7 @@ pub fn pick(exts: &[&str], multiple: bool) { files.push(f); } } - take_files(files); + picked(files, relink); }); return; } @@ -190,7 +221,7 @@ pub fn pick(exts: &[&str], multiple: bool) { let inp = input.clone(); let on_change = Closure::::new(move || { if let Some(list) = inp.files() { - take_files((0..list.length()).filter_map(|i| list.get(i)).collect()); + picked((0..list.length()).filter_map(|i| list.get(i)).collect(), relink.clone()); } }); input.set_onchange(Some(on_change.as_ref().unchecked_ref())); @@ -231,4 +262,12 @@ pub fn install_hooks(app: &mut FilmcraftApp) { // Saving writes the file in memory and offers it as a download (`fs::WebServices`). app.hooks.pick_save = Some(Box::new(|name: &str| Some(format!("/projects/{name}")))); app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| Some(format!("/exports/{name}")))); + // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: the picker is async, so the + // hint's command runs once the user has chosen (#111). + app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], hint: Option| { + if hint.is_some() { + pick_then(exts, false, hint); + } + None + })); } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 79d523b0f..b9b279a12 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -170,6 +170,9 @@ fn main() -> eframe::Result { 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() })); + // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. + app.hooks.pick_file_for_relink = + Some(Box::new(|exts: &[&str], _hint| rfd::FileDialog::new().add_filter("Media", exts).pick_file().map(|p| p.to_string_lossy().to_string()))); app.hooks.pick_save = Some(Box::new(|name: &str| { rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b0afa24a..ba88b5ad8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,6 +88,11 @@ pub struct HostHooks { pub pick_open_file: Option Option>>, /// Folder picker (Link Media search, proxy and Project Manager destinations). pub pick_folder: Option Option>>, + /// Pick one file for a command that relinks to it (Link Media ▸ Locate…, Attach Proxies, + /// Reconnect Full Resolution) instead of importing it, as [`Self::pick_files`] does. Native + /// hosts return the path. A host whose picker is asynchronous (the web) returns `None` and + /// runs `hint.command` with `hint.params` plus `"path"` itself once the user has chosen. + pub pick_file_for_relink: Option) -> Option>>, /// Bring the window on screen for control-channel UI requests *without* taking keyboard focus /// (macOS: `orderFrontRegardless`). Without it the app only requests a repaint: it never /// activates itself for an agent, because the user's keystrokes would land here. @@ -97,6 +102,16 @@ pub struct HostHooks { pub open_path: Option Result<(), String>>>, } +/// The command a [`HostHooks::pick_file_for_relink`] caller runs with the chosen file, for hosts +/// that can only run it later. +#[derive(Clone, Debug)] +pub struct RelinkHint { + /// `media.relink`, `media.attachProxies` or `media.reconnectFullRes`. + pub command: String, + /// Its parameters, without `"path"`. + pub params: serde_json::Value, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Dialog { About, diff --git a/crates/ui-egui/src/panels/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index 3415a0afa..474e085bc 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -17,8 +17,8 @@ use egui::{Color32, RichText}; use serde_json::{Value, json}; -use crate::FilmcraftApp; use crate::state::{LinkMediaDraft, ProjectManagerDraft, ProxyDraft}; +use crate::{FilmcraftApp, RelinkHint}; type Elems = Vec<(String, egui::Rect, String)>; @@ -81,9 +81,16 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option = filmcraft_media::VIDEO_EXTENSIONS.to_vec(); - let path = app.hooks.pick_files.as_mut().and_then(|f| f(&exts).into_iter().next()); - let Some(path) = path else { return Some(Ok(Value::Null)) }; - Some(app.session.execute(id, json!({"item": item.0, "path": path})).map_err(|e| e.to_string())) + let hint = RelinkHint { command: id.to_string(), params: json!({"item": item.0}) }; + let picked = if let Some(picker) = app.hooks.pick_file_for_relink.as_mut() { + picker(&exts, Some(hint)) + } else if let Some(picker) = app.hooks.pick_files.as_mut() { + picker(&exts).into_iter().next() + } else { + None + }; + let Some(picked) = picked else { return Some(Ok(Value::Null)) }; + Some(app.session.execute(id, json!({"item": item.0, "path": picked})).map_err(|e| e.to_string())) } "file.projectManager" => Some(enabled(app, id).map(|_| { let seqs = app.session.state.active_sequence.map(|s| vec![s.0]).unwrap_or_default(); @@ -327,7 +334,17 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { Some("locate") => { let exts: Vec<&str> = filmcraft_media::VIDEO_EXTENSIONS.iter().chain(filmcraft_media::AUDIO_EXTENSIONS).chain(filmcraft_media::STILL_EXTENSIONS).copied().collect(); - if let Some(path) = app.hooks.pick_files.as_mut().and_then(|f| f(&exts).into_iter().next()) { + let mut params = match_params(&d); + params["item"] = json!(item); + let hint = RelinkHint { command: "media.relink".into(), params }; + let picked = if let Some(picker) = app.hooks.pick_file_for_relink.as_mut() { + picker(&exts, Some(hint)) + } else if let Some(picker) = app.hooks.pick_files.as_mut() { + picker(&exts).into_iter().next() + } else { + None + }; + if let Some(path) = picked { relink(app, &mut d, path); } } diff --git a/crates/ui-egui/tests/media_ui.rs b/crates/ui-egui/tests/media_ui.rs index 34bdf20d7..ea1f0849e 100644 --- a/crates/ui-egui/tests/media_ui.rs +++ b/crates/ui-egui/tests/media_ui.rs @@ -258,3 +258,51 @@ fn offline_badges_proxy_toggle_and_dialogs() { assert!(d.app().ui.project_manager.is_none()); let _ = std::fs::remove_dir_all(&root); } + +/// #111: Link Media ▸ Locate… relinks the selected clip to the chosen file instead of importing it, +/// both with a host that returns the path and with one (the web) that runs the hint's command later. +#[test] +fn locate_relinks_instead_of_importing() { + let root = tmp_dir("locate"); + let path = moved_project(&root); + let mut s = Session::default(); + s.execute("file.open", json!({"path": path})).unwrap(); + let mut d = Driver::new(s); + d.frames(6); + let items = d.app().session.project.items.len(); + let imported = Arc::new(std::sync::Mutex::new(0)); + let n = imported.clone(); + d.app().hooks.pick_files = Some(Box::new(move |_| { + *n.lock().unwrap() += 1; + vec![] + })); + // an async host: nothing comes back now, the hint says what to run later + let hints = Arc::new(std::sync::Mutex::new(Vec::new())); + let h = hints.clone(); + d.app().hooks.pick_file_for_relink = Some(Box::new(move |_, hint| { + h.lock().unwrap().push(hint); + None + })); + d.click("linkMedia.locate"); + let hint = hints.lock().unwrap().pop().flatten().expect("the picker got a hint"); + assert_eq!(hint.command, "media.relink"); + let item = hint.params["item"].as_u64().expect("the hint names the clip"); + assert!(hint.params["match"].is_object(), "and the dialog's match options: {}", hint.params); + // what the web host does once the user has chosen + let file = root.join("Moved/Media/Harbour.mov").to_string_lossy().into_owned(); + let mut p = hint.params.clone(); + p["path"] = json!(file); + d.exec(&hint.command, p); + // a host that returns the path relinks the other clip from the dialog itself + let other = root.join("Moved/Media/Night.mov").to_string_lossy().into_owned(); + d.app().hooks.pick_file_for_relink = Some(Box::new(move |_, _| Some(other.clone()))); + if d.app().ui.link_media.is_some() { + d.click("linkMedia.locate"); + d.frames(4); + } + assert_eq!(*imported.lock().unwrap(), 0, "Locate… never goes through the import picker"); + assert_eq!(d.app().session.project.items.len(), items, "no new project item"); + let st = d.exec("media.status", json!({})); + assert!(st.as_array().unwrap().iter().all(|m| m["status"] == "online"), "item {item}: {st}"); + let _ = std::fs::remove_dir_all(&root); +}