Merge pull request #615 from ReReddo/feat/clipboard-paste
timeline, project panel: paste images and copied files from the system clipboard
This commit is contained in:
commit
1879a30db8
|
|
@ -1667,6 +1667,7 @@ dependencies = [
|
|||
name = "filmcraft"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"eframe",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ serde_json = { workspace = true }
|
|||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = "0.16"
|
||||
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
|
||||
arboard = "3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sys-locale = "0.3"
|
||||
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ fn main() -> eframe::Result {
|
|||
app.hooks.pick_folder_at =
|
||||
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
|
||||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
|
||||
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
|
||||
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
|
||||
|
|
@ -283,6 +284,18 @@ fn main() -> eframe::Result {
|
|||
started
|
||||
}
|
||||
|
||||
/// Files copied in the file manager, else an image, on the system clipboard (Paste, #611).
|
||||
fn clipboard_media() -> Option<filmcraft_ui_egui::ClipboardMedia> {
|
||||
let mut clipboard = arboard::Clipboard::new().ok()?;
|
||||
if let Ok(files) = clipboard.get().file_list()
|
||||
&& !files.is_empty()
|
||||
{
|
||||
return Some(filmcraft_ui_egui::ClipboardMedia::Files(files.iter().map(|p| p.to_string_lossy().into_owned()).collect()));
|
||||
}
|
||||
let image = clipboard.get_image().ok()?;
|
||||
Some(filmcraft_ui_egui::ClipboardMedia::Image { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
|
||||
}
|
||||
|
||||
/// Open a file in its default application, or reveal it in the file manager (Edit Original,
|
||||
/// Reveal Log Files).
|
||||
fn open_path(path: &str, reveal: bool) -> Result<(), String> {
|
||||
|
|
|
|||
|
|
@ -706,6 +706,20 @@ pub(crate) fn place_item(
|
|||
})
|
||||
}
|
||||
|
||||
/// How long an item runs on the Timeline without In/Out marks: its duration, or for a still (and a
|
||||
/// generator without a length) Settings ▸ Timeline ▸ Still Image Default Duration.
|
||||
pub(crate) fn full_duration(s: &Session, pi: &filmcraft_project::ProjectItem) -> Tick {
|
||||
match &pi.kind {
|
||||
ItemKind::Media(m)
|
||||
if matches!(m.info.kind, filmcraft_media::MediaKind::Still)
|
||||
|| (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) =>
|
||||
{
|
||||
s.prefs.timeline.still_duration(s.sequence_rate())
|
||||
}
|
||||
_ => pi.duration(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_range(s: &Session) -> Option<(ItemId, TimeRange)> {
|
||||
let item = s.state.source_item?;
|
||||
let pi = s.project.item(item)?;
|
||||
|
|
@ -2133,16 +2147,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() {
|
||||
return Err(bad("timeline.place", "no audio destination: add or enable an audio track"));
|
||||
}
|
||||
let full = match &pi.kind {
|
||||
// Settings ▸ Timeline ▸ Still Image Default Duration
|
||||
ItemKind::Media(m)
|
||||
if matches!(m.info.kind, filmcraft_media::MediaKind::Still)
|
||||
|| (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) =>
|
||||
{
|
||||
s.prefs.timeline.still_duration(s.sequence_rate())
|
||||
}
|
||||
_ => pi.duration(),
|
||||
};
|
||||
let full = full_duration(s, pi);
|
||||
let (mi, mo) = match &pi.kind {
|
||||
ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())),
|
||||
ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())),
|
||||
|
|
@ -2843,6 +2848,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::keyboard::commands());
|
||||
v.extend(crate::project_panel::commands());
|
||||
v.extend(crate::media_browser::commands());
|
||||
v.extend(crate::paste_media::commands());
|
||||
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
|
||||
// and their menu order
|
||||
crate::clip_ops::apply_layout(&mut v);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ pub mod mixer;
|
|||
pub mod multicam;
|
||||
pub mod narration;
|
||||
pub mod panels;
|
||||
pub mod paste_media;
|
||||
pub mod perf;
|
||||
pub mod presets;
|
||||
pub mod previews;
|
||||
|
|
@ -1088,6 +1089,8 @@ mod panels_tests;
|
|||
#[cfg(test)]
|
||||
mod par_tests;
|
||||
#[cfg(test)]
|
||||
mod paste_media_tests;
|
||||
#[cfg(test)]
|
||||
mod presets_tests;
|
||||
#[cfg(test)]
|
||||
mod previews_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
//! Paste media from the system clipboard (#611). The desktop app reads the clipboard: copied files
|
||||
//! paste by path, and an image is first saved by [`save_pasted_image`] as `Pasted Image <n>.png`
|
||||
//! where voice-over takes and narrations go (Scratch Disks ▸ Captured Audio and Video, else next
|
||||
//! to the project).
|
||||
//!
|
||||
//! `edit.pasteMedia` imports the files and puts them on the Timeline at the playhead, end to end,
|
||||
//! without overwriting anything: each clip goes on the first free track from the targeted one
|
||||
//! outward (up for video, down for audio), and a track is added when every track is busy there.
|
||||
//! With `insert` they are inserted on the targeted tracks instead, as Paste Insert does.
|
||||
|
||||
use filmcraft_project::{BinId, ClipId, ItemId, TrackId, TrackKind};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, bad, bool_p, u64_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Write a pasted image (PNG bytes) to a new `Pasted Image <n>.png`; returns its path.
|
||||
pub fn save_pasted_image(s: &mut Session, png: &[u8]) -> Result<String> {
|
||||
let dir = crate::voiceover::media_dir(s, &Value::Null, "Pasted Images");
|
||||
let names: std::collections::HashSet<String> = s.project.items.values().map(|i| i.name.clone()).collect();
|
||||
// bounded like voiceover::unique_wav_path: `file_size` is a filesystem call per try
|
||||
let path = (1..=99_999u32)
|
||||
.map(|k| format!("Pasted Image {k}.png"))
|
||||
.map(|file| (std::path::Path::new(&dir).join(&file).to_string_lossy().into_owned(), file))
|
||||
.find(|(path, file)| !names.contains(file) && s.services.file_size(path).is_err())
|
||||
.map(|(path, _)| path)
|
||||
.ok_or_else(|| EngineError::Other(format!("{dir}: no free file name for \"Pasted Image <n>.png\"")))?;
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?;
|
||||
}
|
||||
s.services.write_file(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn paste_media(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "edit.pasteMedia";
|
||||
let paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
if paths.is_empty() {
|
||||
return Err(bad(cmd, "need `paths`"));
|
||||
}
|
||||
let place = bool_p(p, "place").unwrap_or(true);
|
||||
if place && s.active_sequence().is_none() {
|
||||
return Err(EngineError::NoSequence);
|
||||
}
|
||||
let insert = bool_p(p, "insert").unwrap_or(false);
|
||||
let n0 = s.history.undo.len();
|
||||
let r = import_and_place(s, &paths, u64_p(p, "bin").map(BinId), place, insert);
|
||||
crate::clip_ops::collapse_history(s, n0, if insert { "Paste Insert" } else { "Paste" });
|
||||
r
|
||||
}
|
||||
|
||||
fn import_and_place(s: &mut Session, paths: &[String], bin: Option<BinId>, place: bool, insert: bool) -> Result<Value> {
|
||||
let mut import = json!({"paths": paths});
|
||||
if let Some(b) = bin {
|
||||
import["bin"] = json!(b.0);
|
||||
}
|
||||
let imported = s.execute("file.import", import)?;
|
||||
let items: Vec<ItemId> = imported["items"].as_array().map(|a| a.iter().filter_map(Value::as_u64).map(ItemId).collect()).unwrap_or_default();
|
||||
if !place {
|
||||
return Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "clips": []}));
|
||||
}
|
||||
let mut at = s.playhead();
|
||||
let mut clips: Vec<ClipId> = Vec::new();
|
||||
for item in &items {
|
||||
let Some(pi) = s.project.item(*item) else { continue };
|
||||
let (dur, video, audio) = (crate::commands::full_duration(s, pi), pi.has_video(), pi.has_audio());
|
||||
let mut params = json!({"item": item.0, "time": at.0, "insert": insert});
|
||||
if !insert {
|
||||
if video {
|
||||
params["track"] = json!(free_track(s, TrackKind::Video, at, at + dur)?.0);
|
||||
}
|
||||
if audio {
|
||||
params["audioTrack"] = json!(free_track(s, TrackKind::Audio, at, at + dur)?.0);
|
||||
}
|
||||
}
|
||||
let placed = s.execute("timeline.place", params)?;
|
||||
let ids: Vec<ClipId> = placed["clips"].as_array().map(|a| a.iter().filter_map(Value::as_u64).map(ClipId).collect()).unwrap_or_default();
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
at = ids.iter().filter_map(|c| seq.find_item(*c)).map(|(_, it)| it.end()).max().unwrap_or(at + dur);
|
||||
clips.extend(ids);
|
||||
}
|
||||
// the playhead goes to the end of what was pasted, as after Paste
|
||||
s.set_playhead(at);
|
||||
s.state.selection = clips.clone();
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "clips": clips.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// The first unlocked track of `kind` from the targeted one outward with nothing in `start..end`.
|
||||
/// When every track is busy there, a new one is added (on top for video, at the bottom for audio).
|
||||
fn free_track(s: &mut Session, kind: TrackKind, start: Tick, end: Tick) -> Result<TrackId> {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let tg = s.targeting();
|
||||
let (tracks, dest) = match kind {
|
||||
TrackKind::Video => (&seq.video_tracks, tg.video_dest),
|
||||
TrackKind::Audio => (&seq.audio_tracks, tg.audio_dest),
|
||||
};
|
||||
let from = dest.and_then(|d| tracks.iter().position(|t| t.id == d)).unwrap_or(0);
|
||||
let free = tracks.iter().skip(from).find(|t| !t.locked && !t.items.iter().any(|i| i.start < end && i.end() > start)).map(|t| t.id);
|
||||
if let Some(t) = free {
|
||||
return Ok(t);
|
||||
}
|
||||
let (params, key) = match kind {
|
||||
TrackKind::Video => (json!({"video": 1}), "video"),
|
||||
TrackKind::Audio => (json!({"video": 0, "audio": 1}), "audio"),
|
||||
};
|
||||
let added = s.execute("sequence.addTracks", params)?;
|
||||
added[key][0].as_u64().map(TrackId).ok_or_else(|| EngineError::Other("no track was added".into()))
|
||||
}
|
||||
|
||||
pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: "edit.pasteMedia",
|
||||
label: "Paste Media",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"paths":[str],"insert":bool?,"place":bool=true,"bin":binId?}"#,
|
||||
enabled: always,
|
||||
run: paste_media,
|
||||
journal: true,
|
||||
}]
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
//! Pasting files and images from the system clipboard (#611).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use filmcraft_project::{BinEntry, BinId, ClipId, ItemId, TrackKind};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::media_test_util::tmp_dir;
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn write_png(path: &Path) -> String {
|
||||
image::RgbaImage::from_pixel(64, 36, image::Rgba([40, 120, 200, 255])).save(path).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn write_wav(path: &Path, seconds: f32) -> String {
|
||||
std::fs::write(path, crate::voiceover::write_wav_f32(&vec![0.0; (48_000.0 * seconds) as usize], 48_000)).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Every clip of the active sequence: (track kind, track index, clip id, start, end).
|
||||
fn clips(s: &Session) -> Vec<(TrackKind, usize, u64, Tick, Tick)> {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let mut v = Vec::new();
|
||||
for (kind, tracks) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
|
||||
for (i, t) in tracks.iter().enumerate() {
|
||||
v.extend(t.items.iter().map(|c| (kind, i, c.id.0, c.start, c.end())));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn pasted(r: &Value) -> Vec<u64> {
|
||||
r["clips"].as_array().unwrap().iter().map(|c| c.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// An image pasted over footage goes on the first video track that is free there, from the
|
||||
/// targeted one up; every clip already on the Timeline stays as it was. One undo step takes the
|
||||
/// clip and the imported item back; the playhead moves to its end, as after Paste.
|
||||
#[test]
|
||||
fn a_pasted_image_sits_above_the_footage_without_overwriting_it() {
|
||||
let dir = tmp_dir("paste-media-image");
|
||||
let mut s = demo();
|
||||
let png = write_png(&dir.join("shot.png"));
|
||||
let at = s.playhead();
|
||||
let (before, items, undo) = (clips(&s), s.project.items.len(), s.history.undo.len());
|
||||
assert!(before.iter().any(|c| c.0 == TrackKind::Video && c.1 == 0 && c.3 <= at && c.4 > at), "the playhead is over a V1 clip");
|
||||
let r = s.execute("edit.pasteMedia", json!({"paths": [png]})).unwrap();
|
||||
let ids = pasted(&r);
|
||||
assert_eq!(ids.len(), 1, "a still has picture only");
|
||||
let after = clips(&s);
|
||||
for c in &before {
|
||||
assert!(after.contains(c), "{c:?} is untouched");
|
||||
}
|
||||
let new = *after.iter().find(|c| c.2 == ids[0]).unwrap();
|
||||
assert_eq!((new.0, new.3), (TrackKind::Video, at));
|
||||
assert!(new.1 > 0, "not on V1, where the footage is");
|
||||
assert!(!before.iter().any(|c| c.0 == TrackKind::Video && c.1 == new.1 && c.3 < new.4 && c.4 > new.3), "on a track that was free there");
|
||||
assert!(before.iter().any(|c| c.0 == TrackKind::Video && c.1 < new.1 && c.3 < new.4 && c.4 > new.3), "so it sits above a clip");
|
||||
assert_eq!(new.4 - new.3, s.prefs.timeline.still_duration(s.sequence_rate()), "for the still image default duration");
|
||||
assert_eq!(s.playhead(), new.4);
|
||||
assert_eq!(s.state.selection, vec![ClipId(ids[0])]);
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(s.undo().as_deref(), Some("Paste"));
|
||||
assert_eq!((clips(&s), s.project.items.len()), (before, items), "undo takes back the clip and the import");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// With every video track busy at the playhead a new one is added on top; undo removes it too.
|
||||
/// Audio goes on the first free audio track from the targeted one down, or a new one at the bottom.
|
||||
#[test]
|
||||
fn a_track_is_added_when_every_track_is_busy() {
|
||||
let dir = tmp_dir("paste-media-tracks");
|
||||
let mut s = demo();
|
||||
let png = write_png(&dir.join("a.png"));
|
||||
let wav = write_wav(&dir.join("b.wav"), 2.0);
|
||||
let at = s.playhead();
|
||||
// fill every track at the playhead
|
||||
for _ in 0..8 {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let busy = |ts: &[filmcraft_project::Track]| ts.iter().all(|t| t.items.iter().any(|c| c.start <= at && c.end() > at + Tick::from_seconds_f64(5.0)));
|
||||
if busy(&q.video_tracks) && busy(&q.audio_tracks) {
|
||||
break;
|
||||
}
|
||||
s.set_playhead(at);
|
||||
s.execute("edit.pasteMedia", json!({"paths": [png.clone(), wav.clone()]})).unwrap();
|
||||
}
|
||||
let (nv, na) = {
|
||||
let q = s.active_sequence().unwrap();
|
||||
(q.video_tracks.len(), q.audio_tracks.len())
|
||||
};
|
||||
let before = clips(&s);
|
||||
s.set_playhead(at);
|
||||
let r = s.execute("edit.pasteMedia", json!({"paths": [png, wav]})).unwrap();
|
||||
let ids = pasted(&r);
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na + 1), "one new track of each kind");
|
||||
let after = clips(&s);
|
||||
let image = after.iter().find(|c| c.2 == ids[0]).unwrap();
|
||||
assert_eq!((image.0, image.1), (TrackKind::Video, nv), "the image on the new top track");
|
||||
let sound = after.iter().find(|c| c.2 == ids[1]).unwrap();
|
||||
assert_eq!((sound.0, sound.1, sound.3), (TrackKind::Audio, na, image.4), "the sound on the new bottom track, after the image");
|
||||
for c in &before {
|
||||
assert!(after.contains(c), "{c:?} is untouched");
|
||||
}
|
||||
s.undo();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv, na), "undo removes the tracks it added");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `insert` is Paste Insert: on the targeted video track, pushing later clips right.
|
||||
#[test]
|
||||
fn paste_insert_inserts_on_the_targeted_track() {
|
||||
let dir = tmp_dir("paste-media-insert");
|
||||
let mut s = demo();
|
||||
for t in ["V2", "V3", "A2", "A3"] {
|
||||
let _ = s.execute("timeline.setTrack", json!({"track": t, "syncLock": false}));
|
||||
}
|
||||
let png = write_png(&dir.join("i.png"));
|
||||
let at = s.playhead();
|
||||
let last = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.end()).max().unwrap();
|
||||
let r = s.execute("edit.pasteMedia", json!({"paths": [png], "insert": true})).unwrap();
|
||||
let new = *clips(&s).iter().find(|c| c.2 == pasted(&r)[0]).unwrap();
|
||||
let still = s.prefs.timeline.still_duration(s.sequence_rate());
|
||||
assert_eq!((new.0, new.1, new.3), (TrackKind::Video, 0, at), "on the targeted track, V1");
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.end()).max().unwrap(), last + still, "later clips moved right");
|
||||
assert_eq!(s.undo().as_deref(), Some("Paste Insert"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Without `place` (the Project panel) the files are only imported, into `bin`.
|
||||
#[test]
|
||||
fn without_place_files_are_only_imported() {
|
||||
let dir = tmp_dir("paste-media-import");
|
||||
let mut s = demo();
|
||||
let png = write_png(&dir.join("p.png"));
|
||||
let bin = s.execute("file.newBin", json!({"name": "Pasted"})).unwrap()["bin"].as_u64().unwrap();
|
||||
let before = clips(&s);
|
||||
let r = s.execute("edit.pasteMedia", json!({"paths": [png], "place": false, "bin": bin})).unwrap();
|
||||
assert_eq!(clips(&s), before);
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
let pasted_bin = s.project.root.find_bin(BinId(bin)).unwrap();
|
||||
assert!(pasted_bin.children.iter().any(|e| matches!(e, BinEntry::Item(i) if *i == item)), "imported into the bin");
|
||||
assert!(s.execute("edit.pasteMedia", json!({"paths": []})).is_err(), "nothing to paste");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A pasted image is written as `Pasted Image <n>.png` next to the saved project, with the first
|
||||
/// number no file or project item uses.
|
||||
#[test]
|
||||
fn pasted_images_are_saved_next_to_the_project() {
|
||||
let dir = tmp_dir("paste-media-save");
|
||||
let mut s = demo();
|
||||
s.path = Some(dir.join("film.fcproj").to_string_lossy().into_owned());
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let first = crate::paste_media::save_pasted_image(&mut s, &png).unwrap();
|
||||
assert_eq!(Path::new(&first), dir.join("Pasted Image 1.png"));
|
||||
assert_eq!(std::fs::read(&first).unwrap(), png);
|
||||
let second = crate::paste_media::save_pasted_image(&mut s, &png).unwrap();
|
||||
assert_eq!(Path::new(&second), dir.join("Pasted Image 2.png"));
|
||||
s.execute("edit.pasteMedia", json!({"paths": [second]})).unwrap();
|
||||
assert!(s.project.items.values().any(|i| i.name == "Pasted Image 2.png"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
@ -143,6 +143,17 @@ pub struct HostHooks {
|
|||
/// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto
|
||||
/// uses `egui::Context::system_theme`.
|
||||
pub system_theme: Option<SystemThemeFn>,
|
||||
/// Files or an image on the system clipboard, for Paste in the Timeline and the Project panel
|
||||
/// (#611). Without it Paste only pastes clips copied in FilmCraft.
|
||||
pub clipboard_media: Option<Box<dyn FnMut() -> Option<ClipboardMedia>>>,
|
||||
}
|
||||
|
||||
/// What [`HostHooks::clipboard_media`] found on the system clipboard.
|
||||
pub enum ClipboardMedia {
|
||||
/// Files copied in the file manager.
|
||||
Files(Vec<String>),
|
||||
/// An image (a screenshot, an image copied in a browser): 8-bit RGBA, row by row.
|
||||
Image { width: usize, height: usize, rgba: Vec<u8> },
|
||||
}
|
||||
|
||||
/// Reads the system appearance (Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system).
|
||||
|
|
@ -302,6 +313,8 @@ pub struct FilmcraftApp {
|
|||
pub tl: panels::timeline::TlState,
|
||||
/// Commands from outside the UI (native menu bar), invoked on the UI thread.
|
||||
pub command_inbox: Option<Receiver<String>>,
|
||||
/// A V key press (or a Ctrl+V paste) reached the app and its release hasn't yet.
|
||||
v_down: bool,
|
||||
/// GPU compositor (when running on wgpu): device state + compositor + the egui texture it feeds.
|
||||
pub gpu: Option<GpuState>,
|
||||
/// Preview render job being watched (job id, where to start playing when it completes).
|
||||
|
|
@ -489,6 +502,7 @@ impl FilmcraftApp {
|
|||
toast: None,
|
||||
tl: Default::default(),
|
||||
command_inbox: None,
|
||||
v_down: false,
|
||||
gpu: None,
|
||||
watched_render: None,
|
||||
applied_prefs: None,
|
||||
|
|
@ -1290,6 +1304,30 @@ impl FilmcraftApp {
|
|||
let mut fire = Vec::new();
|
||||
ctx.input_mut(|i| {
|
||||
let modifiers = i.modifiers;
|
||||
// With no text on the system clipboard, egui-winit drops the Ctrl+V press but still
|
||||
// passes its release: put the press back, so Paste can paste files or an image (#611).
|
||||
let mut swallowed = None;
|
||||
for e in &i.events {
|
||||
match e {
|
||||
egui::Event::Key { key: egui::Key::V, pressed, modifiers: m, .. } => {
|
||||
if !pressed && !self.v_down {
|
||||
swallowed = Some(*m);
|
||||
}
|
||||
self.v_down = *pressed;
|
||||
}
|
||||
egui::Event::Paste(_) if modifiers.command => self.v_down = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(m) = swallowed {
|
||||
i.events.push(egui::Event::Key {
|
||||
key: egui::Key::V,
|
||||
physical_key: Some(egui::Key::V),
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: m | egui::Modifiers::COMMAND,
|
||||
});
|
||||
}
|
||||
clipboard_events_as_keys(&mut i.events, modifiers);
|
||||
let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused));
|
||||
let app_wide = self.bindings.iter().filter(|b| b.3.is_none());
|
||||
|
|
|
|||
|
|
@ -490,6 +490,16 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
|
|||
app.dialog = Some(crate::Dialog::RevertConfirm);
|
||||
return Ok(json!({"dialog": "revert"}));
|
||||
}
|
||||
// Files or an image on the system clipboard paste into the Timeline, or import into the
|
||||
// Project panel (#611). Copying clips puts their names there as text, so media on the
|
||||
// clipboard was copied after them; without media, Paste pastes the copied clips.
|
||||
"edit.paste" | "edit.pasteInsert"
|
||||
if params.as_object().is_none_or(|m| m.is_empty()) && matches!(app.ui.focused, PanelKind::Timeline | PanelKind::Project) =>
|
||||
{
|
||||
if let Some(r) = paste_clipboard_media(app, id == "edit.pasteInsert") {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
"file.recover" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
if app.session.recovery_candidates().is_empty() {
|
||||
return Err("there are no unsaved changes to recover".into());
|
||||
|
|
@ -545,6 +555,32 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) {
|
|||
ctx.copy_text(if text.trim().is_empty() { format!("{} clips", names.len()) } else { text });
|
||||
}
|
||||
|
||||
/// Paste the files or the image on the system clipboard: placed on the Timeline when it has focus,
|
||||
/// imported into the shown bin in the Project panel. None when the clipboard holds neither.
|
||||
fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option<Result<Value, String>> {
|
||||
let media = (app.hooks.clipboard_media.as_mut()?)()?;
|
||||
let paths = match media {
|
||||
crate::ClipboardMedia::Files(paths) => paths,
|
||||
crate::ClipboardMedia::Image { width, height, rgba } => match encode_png(width, height, rgba)
|
||||
.and_then(|png| filmcraft_engine::paste_media::save_pasted_image(&mut app.session, &png).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(path) => vec![path],
|
||||
Err(e) => return Some(Err(e)),
|
||||
},
|
||||
};
|
||||
let params = json!({"paths": paths, "insert": insert, "place": app.ui.focused == PanelKind::Timeline, "bin": app.import_bin().0});
|
||||
Some(app.session.execute("edit.pasteMedia", params).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
/// A compressed PNG of a clipboard image; an error when its size and pixels disagree.
|
||||
fn encode_png(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?);
|
||||
let img = image::RgbaImage::from_raw(w, h, rgba).ok_or("the image on the clipboard is malformed")?;
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||
Ok(png)
|
||||
}
|
||||
|
||||
/// A menu tree entry for display / `ui.menu.list`.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct MenuItem {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
//! Paste files and images from the system clipboard (#611): the real `FilmcraftApp` under
|
||||
//! `egui_kittest`, with a stand-in for the desktop app's clipboard hook.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use filmcraft_ui_egui::{ClipboardMedia, FilmcraftApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
/// The demo project saved (in name) in a fresh folder, so pasted images are written there.
|
||||
fn demo(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-clipboard-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut session = Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).expect("demo project");
|
||||
session.path = Some(dir.join("film.fcproj").to_string_lossy().into_owned());
|
||||
let (tx, rx) = channel();
|
||||
let app = FilmcraftApp::new(session).with_control(rx);
|
||||
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, dir };
|
||||
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 ok(&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() {
|
||||
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
||||
return v["result"].clone();
|
||||
}
|
||||
}
|
||||
panic!("no reply to {method} {params}");
|
||||
}
|
||||
|
||||
fn app(&mut self) -> &mut FilmcraftApp {
|
||||
self.harness.state_mut()
|
||||
}
|
||||
|
||||
/// The clipboard hook hands out `media` (a copy each time); returns how often it was asked.
|
||||
fn clipboard(&mut self, media: Option<fn() -> ClipboardMedia>) -> Rc<Cell<usize>> {
|
||||
let asked = Rc::new(Cell::new(0));
|
||||
let count = asked.clone();
|
||||
self.app().hooks.clipboard_media = Some(Box::new(move || {
|
||||
count.set(count.get() + 1);
|
||||
media.map(|m| m())
|
||||
}));
|
||||
asked
|
||||
}
|
||||
|
||||
/// Ctrl+V as egui-winit delivers it when the system clipboard holds no text: the press is
|
||||
/// dropped, only the release arrives.
|
||||
fn ctrl_v_without_text(&mut self, modifiers: egui::Modifiers) {
|
||||
let key = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: Some(egui::Key::V), pressed, repeat: false, modifiers };
|
||||
self.harness.input_mut().events.extend([egui::Event::ModifiersChanged(modifiers), key(false)]);
|
||||
self.frames(1);
|
||||
self.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
self.frames(2);
|
||||
}
|
||||
|
||||
/// Ctrl+V with text on the clipboard: egui-winit sends `Event::Paste`, then the release.
|
||||
fn ctrl_v_with_text(&mut self) {
|
||||
let release =
|
||||
egui::Event::Key { key: egui::Key::V, physical_key: Some(egui::Key::V), pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||
self.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::Paste("text".into()), release]);
|
||||
self.frames(1);
|
||||
self.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
self.frames(2);
|
||||
}
|
||||
|
||||
/// (track kind, track index, clip id, item name, start, end) of every clip, video first.
|
||||
fn clips(&mut self) -> Vec<(char, usize, u64, String, i64, i64)> {
|
||||
let q = self.app().session.active_sequence().unwrap().clone();
|
||||
let mut v = Vec::new();
|
||||
for (kind, tracks) in [('V', &q.video_tracks), ('A', &q.audio_tracks)] {
|
||||
for (i, t) in tracks.iter().enumerate() {
|
||||
v.extend(t.items.iter().map(|c| (kind, i, c.id.0, c.name.clone(), c.start.0, c.end().0)));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Driver {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn red_image() -> ClipboardMedia {
|
||||
ClipboardMedia::Image { width: 32, height: 18, rgba: [220, 30, 30, 255].repeat(32 * 18) }
|
||||
}
|
||||
|
||||
/// Ctrl+V in the Timeline with an image on the clipboard (no text, so only the key release
|
||||
/// arrives) saves it next to the project and places it at the playhead above the footage,
|
||||
/// overwriting nothing. One undo takes it back.
|
||||
#[test]
|
||||
fn ctrl_v_pastes_an_image_above_the_footage() {
|
||||
let mut d = Driver::demo("image");
|
||||
d.ok("ui.set", json!({"focused": "Timeline"}));
|
||||
d.clipboard(Some(red_image));
|
||||
let before = d.clips();
|
||||
let at = d.app().session.playhead().0;
|
||||
d.ctrl_v_without_text(egui::Modifiers::COMMAND);
|
||||
let after = d.clips();
|
||||
for c in &before {
|
||||
assert!(after.contains(c), "{c:?} is untouched");
|
||||
}
|
||||
let new: Vec<_> = after.iter().filter(|c| !before.contains(c)).cloned().collect();
|
||||
assert_eq!(new.len(), 1, "one clip pasted: {new:?}");
|
||||
let (kind, track, _, name, start, end) = new[0].clone();
|
||||
assert_eq!((kind, name.as_str(), start), ('V', "Pasted Image 1.png", at));
|
||||
assert!(track > 0 && before.iter().any(|c| c.0 == 'V' && c.1 < track && c.4 < end && c.5 > start), "it sits above a clip");
|
||||
assert!(d.dir.join("Pasted Image 1.png").is_file(), "saved next to the project");
|
||||
d.ok("ui.menu.invoke", json!({"id": "edit.undo"}));
|
||||
assert_eq!(d.clips(), before);
|
||||
}
|
||||
|
||||
/// Files copied in the file manager paste the same way, even when the clipboard also holds text
|
||||
/// (Ctrl+V then arrives as a paste event); Ctrl+Shift+V inserts them on the targeted track.
|
||||
#[test]
|
||||
fn copied_files_paste_and_paste_insert_inserts() {
|
||||
let mut d = Driver::demo("files");
|
||||
d.ok("ui.set", json!({"focused": "Timeline"}));
|
||||
let png = d.dir.join("copied.png");
|
||||
image::RgbaImage::from_pixel(16, 9, image::Rgba([0, 200, 0, 255])).save(&png).unwrap();
|
||||
let path = png.to_string_lossy().into_owned();
|
||||
d.app().hooks.clipboard_media = Some(Box::new(move || Some(ClipboardMedia::Files(vec![path.clone()]))));
|
||||
let before = d.clips();
|
||||
d.ctrl_v_with_text();
|
||||
let new: Vec<_> = d.clips().into_iter().filter(|c| !before.contains(c)).collect();
|
||||
assert_eq!(new.iter().map(|c| c.3.as_str()).collect::<Vec<_>>(), ["copied.png"]);
|
||||
assert!(new[0].1 > 0, "not over the footage on V1");
|
||||
// Paste Insert: on V1, pushing the clips after the playhead right
|
||||
let v1_end = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.1 == 0).map(|c| c.5).max().unwrap();
|
||||
let end = v1_end(&mut d);
|
||||
d.ctrl_v_without_text(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
|
||||
assert!(v1_end(&mut d) > end, "Ctrl+Shift+V inserts on V1");
|
||||
}
|
||||
|
||||
/// In the Project panel Ctrl+V imports into the shown bin and places nothing.
|
||||
#[test]
|
||||
fn ctrl_v_in_the_project_panel_imports() {
|
||||
let mut d = Driver::demo("project");
|
||||
d.ok("ui.set", json!({"focused": "Project"}));
|
||||
d.clipboard(Some(red_image));
|
||||
let (before, items) = (d.clips(), d.app().session.project.items.len());
|
||||
d.ctrl_v_without_text(egui::Modifiers::COMMAND);
|
||||
assert_eq!(d.clips(), before, "nothing on the Timeline");
|
||||
assert_eq!(d.app().session.project.items.len(), items + 1);
|
||||
assert!(d.app().session.project.items.values().any(|i| i.name == "Pasted Image 1.png"));
|
||||
}
|
||||
|
||||
/// With nothing but clips copied in FilmCraft, Ctrl+V pastes the clips as before, also when the
|
||||
/// system clipboard holds no text at all. A plain V press and release (the Selection tool) pastes
|
||||
/// nothing and doesn't read the clipboard.
|
||||
#[test]
|
||||
fn without_media_ctrl_v_pastes_copied_clips() {
|
||||
let mut d = Driver::demo("clips");
|
||||
d.ok("ui.set", json!({"focused": "Timeline"}));
|
||||
let asked = d.clipboard(None);
|
||||
let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap();
|
||||
d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}}));
|
||||
d.ok("engine.execute", json!({"command": "edit.copy", "params": {}}));
|
||||
let copies = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count();
|
||||
let n = copies(&mut d);
|
||||
d.ctrl_v_without_text(egui::Modifiers::COMMAND);
|
||||
assert_eq!(copies(&mut d), n + 1, "the copied clip is pasted");
|
||||
assert_eq!(asked.get(), 1);
|
||||
for pressed in [true, false] {
|
||||
d.harness.input_mut().events.push(egui::Event::Key {
|
||||
key: egui::Key::V,
|
||||
physical_key: Some(egui::Key::V),
|
||||
pressed,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
d.frames(1);
|
||||
}
|
||||
d.frames(2);
|
||||
assert_eq!((copies(&mut d), asked.get()), (n + 1, 1), "V alone pastes nothing");
|
||||
}
|
||||
Loading…
Reference in New Issue