From 02f2a5da86056f7d314574c34fc294dce2a799b2 Mon Sep 17 00:00:00 2001 From: Logan King Date: Thu, 26 Jun 2025 21:44:53 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20convert:=20add=20clipboard=20fi?= =?UTF-8?q?le=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/convert.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/convert.rs diff --git a/src/convert.rs b/src/convert.rs new file mode 100644 index 0000000..50fd44f --- /dev/null +++ b/src/convert.rs @@ -0,0 +1,65 @@ +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use uuid::Uuid; + +/// Convert `input` to `output_ext` using ffmpeg and return the path +/// to the converted file. +/// +/// The resulting file is created in the system temp directory with a +/// random name so the original file is never overwritten. +pub fn convert_with_ffmpeg(input: &Path, output_ext: &str) -> Result { + let out_path = std::env::temp_dir().join(format!("converted-{}.{output_ext}", Uuid::new_v4())); + + let status = Command::new("ffmpeg") + .args([ + "-y", + "-i", + input + .to_str() + .context("Failed to convert input path to string")?, + out_path + .to_str() + .context("Failed to convert output path to string")?, + ]) + .status() + .context("Failed to execute ffmpeg")?; + + if !status.success() { + bail!("ffmpeg failed to convert file"); + } + + Ok(out_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + + #[test] + fn convert_with_ffmpeg_fails_if_ffmpeg_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let ffmpeg_path = dir.path().join("ffmpeg"); + fs::write(&ffmpeg_path, "#!/bin/sh\nexit 1\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&ffmpeg_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&ffmpeg_path, perms).unwrap(); + } + + let old_path = env::var("PATH").unwrap_or_default(); + env::set_var("PATH", format!("{}:{}", dir.path().display(), old_path)); + + let input = dir.path().join("in.wav"); + fs::write(&input, b"dummy").unwrap(); + + let res = convert_with_ffmpeg(&input, "mp3"); + assert!(res.is_err()); + + env::set_var("PATH", old_path); + } +} diff --git a/src/main.rs b/src/main.rs index 62d950b..a522e27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use timers::*; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::filter::FilterFn; use tracing_subscriber::Registry; +mod convert; mod default_device_sink; mod timers; mod transcribe; @@ -567,6 +568,44 @@ fn call_fn( } } + "convert_clipboard_file" => { + let args = match serde_json::from_str::(fn_args) { + Ok(json) => json, + Err(e) => return Some(format!("Failed to parse arguments: {}", e)), + }; + + let output_ext = match args["output_ext"].as_str() { + Some(ext) => ext, + None => return Some("Missing 'output_ext' argument.".to_string()), + }; + + let mut clipboard: ClipboardContext = match ClipboardProvider::new() { + Ok(c) => c, + Err(e) => return Some(format!("Failed to initialize clipboard: {}", e)), + }; + + let clipboard_text = match clipboard.get_contents() { + Ok(t) => t, + Err(e) => return Some(format!("Failed to read clipboard contents: {}", e)), + }; + + let input_path = Path::new(clipboard_text.trim()); + if !input_path.is_file() { + return Some("Clipboard does not contain a valid file path.".to_string()); + } + + match convert::convert_with_ffmpeg(input_path, output_ext) { + Ok(out) => match clipboard.set_contents(out.to_string_lossy().to_string()) { + Ok(_) => Some(format!( + "Converted file copied to clipboard as {}", + out.display() + )), + Err(e) => Some(format!("Failed to set clipboard contents: {}", e)), + }, + Err(e) => Some(format!("Failed to convert file: {}", e)), + } + } + "set_speech_speed" => { let args: serde_json::Value = serde_json::from_str(fn_args).unwrap(); if let Some(speed) = args["speed"].as_f64() { @@ -1530,6 +1569,16 @@ async fn main() -> Result<(), Box> { })) .build().unwrap(), + ChatCompletionFunctionsArgs::default() + .name("convert_clipboard_file") + .description("Converts the file pointed to by clipboard text to another format using ffmpeg and copies the new file path to the clipboard.") + .parameters(json!({ + "type": "object", + "properties": {"output_ext": {"type": "string"}}, + "required": ["output_ext"], + })) + .build().unwrap(), + ChatCompletionFunctionsArgs::default() .name("set_speech_speed") .description("Sets how fast the AI voice speaks. Speed must be between 0.5 and 100.0.") From 7892890142be86a128994ad371140982be5105fb Mon Sep 17 00:00:00 2001 From: Logan King Date: Fri, 27 Jun 2025 22:18:43 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20clipboard:=20handle=20file=20ob?= =?UTF-8?q?jects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 16 ++++++++++++++++ Cargo.toml | 1 + src/convert.rs | 36 ++++++++++++++++++++++++++++++++++++ src/main.rs | 32 +++++++------------------------- 4 files changed, 60 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c156012..9f84e61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -984,6 +984,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "clipboard-win" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +dependencies = [ + "error-code", +] + [[package]] name = "clru" version = "0.6.1" @@ -1774,6 +1783,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "euclid" version = "0.22.9" @@ -4113,6 +4128,7 @@ dependencies = [ "chrono", "clap", "clipboard", + "clipboard-win 5.4.0", "colored", "cpal", "csv", diff --git a/Cargo.toml b/Cargo.toml index 7fb5897..be8b74b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ sysinfo = "0.32.1" csv = "1.3.1" humantime = "2.1.0" clipboard = "0.5.0" +clipboard-win = "5.4.0" reqwest = { version = "0.12.1", features = ["json"] } serde = { version = "1.0", features = ["derive"] } once_cell = "1.19.0" diff --git a/src/convert.rs b/src/convert.rs index 50fd44f..5445fc5 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -2,6 +2,8 @@ use anyhow::{bail, Context, Result}; use std::path::{Path, PathBuf}; use std::process::Command; use uuid::Uuid; +#[cfg(target_os = "windows")] +use clipboard_win::{formats::FileList, Clipboard, Getter, Setter}; /// Convert `input` to `output_ext` using ffmpeg and return the path /// to the converted file. @@ -32,6 +34,40 @@ pub fn convert_with_ffmpeg(input: &Path, output_ext: &str) -> Result { Ok(out_path) } +/// Convert the file currently stored in the clipboard to `output_ext` +/// using ffmpeg and put the resulting file back on the clipboard. +/// +/// On non-Windows platforms this returns an error. +#[cfg(target_os = "windows")] +pub fn convert_clipboard_file(output_ext: &str) -> Result { + let _clip = Clipboard::new_attempts(10) + .map_err(|e| anyhow::anyhow!("Failed to open clipboard: {e:?}"))?; + + let mut files = Vec::::new(); + FileList + .read_clipboard(&mut files) + .map_err(|e| anyhow::anyhow!("Failed to read clipboard files: {e:?}"))?; + + let input = files + .get(0) + .cloned() + .context("Clipboard does not contain a file")?; + + let out = convert_with_ffmpeg(&input, output_ext)?; + + let out_str = out.to_string_lossy().to_string(); + FileList + .write_clipboard(&[out_str.as_str()]) + .map_err(|e| anyhow::anyhow!("Failed to set clipboard files: {e:?}"))?; + + Ok(out) +} + +#[cfg(not(target_os = "windows"))] +pub fn convert_clipboard_file(_output_ext: &str) -> Result { + bail!("convert_clipboard_file is only supported on Windows") +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index a522e27..76019c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -579,30 +579,12 @@ fn call_fn( None => return Some("Missing 'output_ext' argument.".to_string()), }; - let mut clipboard: ClipboardContext = match ClipboardProvider::new() { - Ok(c) => c, - Err(e) => return Some(format!("Failed to initialize clipboard: {}", e)), - }; - - let clipboard_text = match clipboard.get_contents() { - Ok(t) => t, - Err(e) => return Some(format!("Failed to read clipboard contents: {}", e)), - }; - - let input_path = Path::new(clipboard_text.trim()); - if !input_path.is_file() { - return Some("Clipboard does not contain a valid file path.".to_string()); - } - - match convert::convert_with_ffmpeg(input_path, output_ext) { - Ok(out) => match clipboard.set_contents(out.to_string_lossy().to_string()) { - Ok(_) => Some(format!( - "Converted file copied to clipboard as {}", - out.display() - )), - Err(e) => Some(format!("Failed to set clipboard contents: {}", e)), - }, - Err(e) => Some(format!("Failed to convert file: {}", e)), + match convert::convert_clipboard_file(output_ext) { + Ok(out) => Some(format!( + "Converted file copied to clipboard as {}", + out.display() + )), + Err(e) => Some(format!("Failed to convert clipboard file: {}", e)), } } @@ -1571,7 +1553,7 @@ async fn main() -> Result<(), Box> { ChatCompletionFunctionsArgs::default() .name("convert_clipboard_file") - .description("Converts the file pointed to by clipboard text to another format using ffmpeg and copies the new file path to the clipboard.") + .description("Converts the file currently stored in the clipboard to another format using ffmpeg and copies the new file back to the clipboard.") .parameters(json!({ "type": "object", "properties": {"output_ext": {"type": "string"}},