diff --git a/flake.lock b/flake.lock index 8c45108..18679db 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1746291859, - "narHash": "sha256-DdWJLA+D5tcmrRSg5Y7tp/qWaD05ATI4Z7h22gd1h7Q=", + "lastModified": 1747587869, + "narHash": "sha256-Zay3WJdSvC2VQmNqWSVLBOg/1iS/0/Q0c9JOBsB+3qw=", "owner": "ipetkov", "repo": "crane", - "rev": "dfd9a8dfd09db9aad544c4d3b6c47b12562544a5", + "rev": "76603d32f18e0e378d9f6335c8fc286413493655", "type": "github" }, "original": { @@ -37,11 +37,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1746461020, - "narHash": "sha256-7+pG1I9jvxNlmln4YgnlW4o+w0TZX24k688mibiFDUE=", + "lastModified": 1747542820, + "narHash": "sha256-GaOZntlJ6gPPbbkTLjbd8BMWaDYafhuuYRNrxCGnPJw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3730d8a308f94996a9ba7c7138ede69c1b9ac4ae", + "rev": "292fa7d4f6519c074f0a50394dbbe69859bb6043", "type": "github" }, "original": { diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index 0c3e036..65f12c9 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -3,7 +3,8 @@ use std::{ process::Stdio, }; -use color_eyre::eyre::{ContextCompat, Result}; +use color_eyre::eyre::{ContextCompat, Result, bail}; +use futures::channel::oneshot; use relm4::{ Sender, tokio::{ @@ -22,9 +23,10 @@ struct FfprobeOut { } #[derive(serde::Deserialize)] -struct StreamInfo { - width: usize, - height: usize, +#[serde(tag = "codec_type", rename_all = "lowercase")] +enum StreamInfo { + Video { width: usize, height: usize }, + Audio, } #[derive(serde::Deserialize)] @@ -36,6 +38,7 @@ struct FormatInfo { pub struct VideoMeta { pub width: usize, pub height: usize, + pub audio_streams: usize, pub duration_us: usize, } @@ -61,10 +64,8 @@ pub async fn get_video_meta(path: &Path) -> Result { .args([ "-v", "error", - "-select_streams", - "v:0", "-show_entries", - "stream=width,height : format=duration", + "stream=width,height,codec_type : format=duration", "-of", "json", ]) @@ -76,17 +77,28 @@ pub async fn get_video_meta(path: &Path) -> Result { let str = String::from_utf8(output.stdout)?; let output: FfprobeOut = serde_json::from_str(&str)?; - let stream = output + let (width, height) = output .streams - .first() + .iter() + .find_map(|s| match s { + StreamInfo::Video { width, height } => Some((*width, *height)), + _ => None, + }) .wrap_err("could not get stream information from ffprobe")?; + let audio_streams = output + .streams + .iter() + .filter(|s| matches!(s, StreamInfo::Audio)) + .count(); + let duration_sec = output.format.duration.parse::()?; let duration_us = (duration_sec * 1_000_000.0).ceil() as usize; Ok(VideoMeta { - width: stream.width, - height: stream.height, + width, + height, + audio_streams, duration_us, }) } @@ -98,14 +110,22 @@ pub async fn convert_video( merge_tracks: bool, sender: Sender, ) -> Result { - let out_path = PathBuf::from("/tmp/out.webm"); + let out_filename = out_filename + .or_else(|| { + path.file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_owned()) + }) + .unwrap_or("out.webm".into()); + + let mut out_path = std::env::temp_dir().join(out_filename); + out_path.set_extension("webm"); let codec_args: &[&str] = match out_codec { Codec::AV1 => &["-c:v", "libsvtav1"], Codec::VP9 => &["-c:v", "libvpx-vp9", "-row-mt", "1"], }; - // TODO: maybe check if the video has 2 audio tracks? or at least use a "fail-safe" method let merge_args: &[&str] = if merge_tracks { &["-ac", "2", "-filter_complex", "amerge=inputs=2"] } else { @@ -118,7 +138,7 @@ pub async fn convert_video( .args(["-c:a", "libopus", "-b:a", "96k"]) .args(codec_args) .args(merge_args) - .args(["-loglevel", "error", "-progress", "-", "-nostats"]) + .args(["-y", "-loglevel", "error", "-progress", "-", "-nostats"]) .arg(&out_path) .stdout(Stdio::piped()) .spawn()?; @@ -127,11 +147,10 @@ pub async fn convert_video( let mut reader = BufReader::new(stdout).lines(); // make sure the process is actually started and awaited + let (tx, rx) = oneshot::channel(); tokio::spawn(async move { - child - .wait() - .await - .expect("ffmpeg process encountered an error"); + tx.send(child.wait().await) + .expect("could not send exit status"); }); while let Some(line) = reader.next_line().await? { @@ -148,5 +167,10 @@ pub async fn convert_video( } } + let status = rx.await??; + if !status.success() { + bail!("ffmpeg process errored: {status}"); + } + Ok(out_path) } diff --git a/src/main.rs b/src/main.rs index fb0be26..b8926dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use relm4::{ Component, ComponentController, Controller, RelmApp, Sender, adw::{self, prelude::*}, gtk::{self, gio}, + loading_widgets::LoadingWidgets, prelude::{AsyncComponent, AsyncComponentParts}, tokio, }; @@ -94,6 +95,7 @@ struct Tyrolienne { step: Step, progress: usize, total: usize, + can_merge: bool, video_path: Option, out_filename: Option, out_codec: ffmpeg::Codec, @@ -116,7 +118,7 @@ impl Tyrolienne { config: self.config.clone(), out_filename: self.out_filename.clone(), out_codec: self.out_codec, - merge_tracks: self.merge_tracks, + merge_tracks: self.can_merge && self.merge_tracks, video_path: self.video_path.clone(), folder: self.folder.clone(), } @@ -190,7 +192,10 @@ impl AsyncComponent for Tyrolienne { adw::SwitchRow { set_title: "Merge audio tracks", - set_active: true, + #[watch] + set_sensitive: model.can_merge, + #[watch] + set_active: model.can_merge && model.merge_tracks, connect_active_notify[sender] => move |s| sender.input(Message::SetMergeTracks(s.is_active())), }, @@ -246,6 +251,7 @@ impl AsyncComponent for Tyrolienne { step: Step::Waiting, progress: 0, total: 1, + can_merge: false, video_path: None, out_filename: None, out_codec: ffmpeg::Codec::VP9, @@ -259,8 +265,6 @@ impl AsyncComponent for Tyrolienne { .forward(sender.input_sender(), |_| Message::Nothing), }; - // TODO consider using the "loading" (?) mechanism from relm4 - // https://relm4.org/book/stable/threads_and_async/async.html let folders = match zipline::get_folders(&model.config).await { Ok(v) => v, Err(e) => { @@ -291,6 +295,21 @@ impl AsyncComponent for Tyrolienne { AsyncComponentParts { model, widgets } } + fn init_loading_widgets(root: Self::Root) -> Option { + relm4::view! { + #[local] + root { + #[name(spinner)] + gtk::Spinner { + start: (), + set_halign: gtk::Align::Center, + } + } + } + + Some(LoadingWidgets::new(root, spinner)) + } + async fn update( &mut self, message: Self::Input, @@ -306,6 +325,8 @@ impl AsyncComponent for Tyrolienne { .await; if let Some(file) = file { + let meta = ffmpeg::get_video_meta(file.path()).await; + self.can_merge = meta.map(|m| m.audio_streams == 2).unwrap_or(false); self.video_path = Some(file.path().to_owned()); } } @@ -353,9 +374,7 @@ impl AsyncComponent for Tyrolienne { self.step = Step::Waiting; self.progress = 0; self.total = 1; - - // TODO copy to clipboard here instead, the toast disappears after a short while - self.toast.emit(ToastInput::Show(url)); + self.toast.emit(ToastInput::ShowAndCopy(url)); } ProgressMessage::Error(e) => { self.step = Step::Waiting; @@ -459,7 +478,6 @@ async fn the_process(app: UploadInfo, sender: &Sender) -> Resul .thumbnail_url(&app.config) .ok_or_eyre("could not get thumbnail url")?; - // TODO get w&h from video Ok(format!( "https://autocompressor.net/av1?v={}&i={}&w={}&h={}", Encoded(&zp_file.url), diff --git a/src/relm.rs b/src/relm.rs index 4a230f4..0b0c578 100644 --- a/src/relm.rs +++ b/src/relm.rs @@ -1,3 +1,4 @@ +use arboard::Clipboard; use relm4::{ SimpleComponent, adw::{self, prelude::*}, @@ -91,8 +92,7 @@ pub struct Toast { #[derive(Debug)] pub enum ToastInput { - Show(String), - Copy, + ShowAndCopy(String), Dismiss, } @@ -107,8 +107,6 @@ impl SimpleComponent for Toast { adw::Toast { #[watch] set_title: &model.text, - set_button_label: Some("Copy"), - connect_button_clicked => ToastInput::Copy, connect_dismissed => ToastInput::Dismiss, } } @@ -130,18 +128,14 @@ impl SimpleComponent for Toast { fn update(&mut self, message: Self::Input, _sender: relm4::ComponentSender) { match message { - ToastInput::Show(text) => { + ToastInput::ShowAndCopy(text) => { self.visible = true; - self.text = text; - } - ToastInput::Copy => { - if let Err(e) = - arboard::Clipboard::new().and_then(|mut c| c.set_text(self.text.clone())) - { + + if let Err(e) = Clipboard::new().and_then(|mut c| c.set_text(text)) { tracing::error!("could not copy url to clipboard: {e}"); self.text = "Could not copy".into(); } else { - self.visible = false; + self.text = "Copied url to clipboard".into(); } } ToastInput::Dismiss => self.visible = false,