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:
Brandon Thomas 2026-10-10 14:53:02 -04:00 committed by GitHub
commit 1879a30db8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 611 additions and 10 deletions

1
Cargo.lock generated
View File

@ -1667,6 +1667,7 @@ dependencies = [
name = "filmcraft"
version = "0.5.0"
dependencies = [
"arboard",
"chrono",
"cpal",
"eframe",

View File

@ -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"

View File

@ -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> {

View File

@ -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);

View File

@ -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;

View File

@ -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,
}]
}

View File

@ -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);
}

View File

@ -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());

View File

@ -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 {

View File

@ -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");
}