From 8f3c7e4052ad07dcab52ce0b532c1c592261615e Mon Sep 17 00:00:00 2001 From: uku Date: Tue, 13 May 2025 14:22:03 +0200 Subject: [PATCH] feat: add initial ffmpeg implementation --- Cargo.lock | 1 + Cargo.toml | 1 + package.nix | 10 +++- src/ffmpeg.rs | 126 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 129 ++++++++++++++++++++++++++++++++++++++----------- src/zipline.rs | 2 +- 6 files changed, 237 insertions(+), 32 deletions(-) create mode 100644 src/ffmpeg.rs diff --git a/Cargo.lock b/Cargo.lock index 2785721..e9ccbeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2405,6 +2405,7 @@ dependencies = [ "reqwest", "rfd", "serde", + "serde_json", "tokio-util", "toml", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 1c030ed..8d7287a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ relm4 = { version = "0.9.1", features = ["gnome_47", "libadwaita"] } reqwest = { version = "0.12.15", default-features = false, features = ["http2", "charset", "rustls-tls", "json", "multipart", "stream"] } rfd = { version = "0.15.3", default-features = false, features = ["tokio", "xdg-portal"] } serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" tokio-util = { version = "0.7.15", features = ["io"] } toml = "0.8.22" tracing = "0.1.41" diff --git a/package.nix b/package.nix index db4f9ea..df2be8a 100644 --- a/package.nix +++ b/package.nix @@ -1,8 +1,9 @@ { lib, craneLib, - mold-wrapped, + ffmpeg, libadwaita, + mold-wrapped, pkg-config, wrapGAppsHook4, zenity, @@ -30,7 +31,12 @@ craneLib.buildPackage { preFixup = '' gappsWrapperArgs+=( - --prefix PATH : ${lib.makeBinPath [ zenity ]} + --prefix PATH : ${ + lib.makeBinPath [ + ffmpeg + zenity + ] + } ) ''; diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs new file mode 100644 index 0000000..dbaa463 --- /dev/null +++ b/src/ffmpeg.rs @@ -0,0 +1,126 @@ +use std::{ + path::{Path, PathBuf}, + process::Stdio, +}; + +use color_eyre::eyre::{ContextCompat, Result}; +use relm4::{ + Sender, + tokio::{ + self, + io::{AsyncBufReadExt, BufReader}, + process::Command, + }, +}; + +use crate::ProgressMessage; + +#[derive(serde::Deserialize)] +struct FfprobeOut { + streams: Vec, + format: FormatInfo, +} + +#[derive(serde::Deserialize)] +struct StreamInfo { + width: usize, + height: usize, +} + +#[derive(serde::Deserialize)] +struct FormatInfo { + duration: String, +} + +#[derive(Debug)] +pub struct VideoMeta { + pub width: usize, + pub height: usize, + pub duration_us: usize, +} + +pub async fn get_video_meta(path: &Path) -> Result { + let output = Command::new("ffprobe") + .args([ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height : format=duration", + "-of", + "json", + ]) + .arg(path) + .stdout(Stdio::piped()) + .output() + .await?; + + let str = String::from_utf8(output.stdout)?; + let output: FfprobeOut = serde_json::from_str(&str)?; + + let stream = output + .streams + .first() + .wrap_err("could not get stream information from ffprobe")?; + + 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, + duration_us, + }) +} + +pub async fn convert_video(path: &Path, sender: Sender) -> Result { + let out_path = PathBuf::from("/tmp/out.webm"); + + let mut child = Command::new("ffmpeg") + .arg("-i") + .arg(path) + .args([ + "-c:a", + "libopus", + "-b:a", + "96k", + "-c:v", + "libsvtav1", + "-loglevel", + "error", + "-progress", + "-", + "-nostats", + ]) + .arg(&out_path) + .stdout(Stdio::piped()) + .spawn()?; + + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout).lines(); + + // make sure the process is actually started and awaited + tokio::spawn(async move { + child + .wait() + .await + .expect("ffmpeg process encountered an error"); + }); + + while let Some(line) = reader.next_line().await? { + if line.starts_with("out_time_us") { + let (_, current_duration) = line + .split_once("=") + .wrap_err_with(|| format!("could not parse ffmpeg output: {line}"))?; + + if current_duration != "N/A" { + sender.emit(ProgressMessage::AbsProgress( + current_duration.parse::()?, + )); + } + } + } + + Ok(out_path) +} diff --git a/src/main.rs b/src/main.rs index abfffe7..72aed87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod ffmpeg; mod gobject; mod relm; mod zipline; @@ -42,6 +43,8 @@ impl Config { #[derive(Debug)] enum Message { OpenFilePicker, + SetOutFilename(String), + SetMergeTracks(bool), SetFolder(ZiplineFolder), StartTheProcess, Nothing, @@ -49,24 +52,49 @@ enum Message { #[derive(Debug)] enum ProgressMessage { + SetStep(Step), SetTotal(usize), - Progress(usize), + AbsProgress(usize), + IncProgress(usize), Finish(String), Error(String), } +#[derive(Debug)] +enum Step { + Waiting, + Converting, + Uploading, + Thumbnail, +} + +impl Step { + fn button_text(&self) -> &'static str { + match self { + Step::Waiting => "Send", + Step::Converting => "Converting...", + Step::Uploading => "Uploading...", + Step::Thumbnail => "Generating thumbnail...", + } + } +} + struct UploadInfo { config: Config, video_path: Option, + out_filename: Option, + merge_tracks: bool, folder: Option, } struct Tyrolienne { config: Config, - locked: bool, + step: Step, progress: usize, total: usize, video_path: Option, + out_filename: Option, + merge_tracks: bool, folder: Option, dialog: Controller, toast: Controller, @@ -83,6 +111,8 @@ impl Tyrolienne { fn clone_as_info(&self) -> UploadInfo { UploadInfo { config: self.config.clone(), + out_filename: self.out_filename.clone(), + merge_tracks: self.merge_tracks, video_path: self.video_path.clone(), folder: self.folder.clone(), } @@ -128,11 +158,26 @@ impl AsyncComponent for Tyrolienne { set_title: "Video file", #[watch] set_subtitle: &model.display_video_path(), + add_suffix = >k::Image { + set_margin_end: 8, + set_icon_name: Some("document-open-symbolic"), + }, connect_activated => Message::OpenFilePicker, }, + adw::EntryRow { + set_title: "Output file name", + connect_changed[sender] => move |e| sender.input(Message::SetOutFilename(e.text().into())), + }, + + adw::SwitchRow { + set_title: "Merge audio tracks", + set_active: true, + connect_active_notify[sender] => move |s| sender.input(Message::SetMergeTracks(s.is_active())), + }, + adw::ComboRow { - set_title: "Folder", + set_title: "Zipline folder", set_model: Some(&folder_store), set_expression: Some(&folder_expression), connect_activated[sender] => move |r| { @@ -158,9 +203,10 @@ impl AsyncComponent for Tyrolienne { gtk::Button { #[watch] - set_label: if model.locked { "Uploading..." } else { "Send" }, + set_label: model.step.button_text(), #[watch] - set_sensitive: !model.locked && model.video_path.is_some(), + set_sensitive: matches!(model.step, Step::Waiting) && model.video_path.is_some(), + set_css_classes: &["suggested-action"], connect_clicked => Message::StartTheProcess, } } @@ -179,10 +225,12 @@ impl AsyncComponent for Tyrolienne { let model = Tyrolienne { config, - locked: false, + step: Step::Waiting, progress: 0, total: 1, video_path: None, + out_filename: None, + merge_tracks: true, folder: None, dialog: Dialog::builder() .launch(toast_overlay.clone()) @@ -232,9 +280,6 @@ impl AsyncComponent for Tyrolienne { ) { match message { Message::Nothing => {} - Message::SetFolder(folder) => { - self.folder = (folder.id != GtkZiplineFolder::NONE_ID).then_some(folder) - } Message::OpenFilePicker => { let file = rfd::AsyncFileDialog::new() .add_filter("Video file", &["mp4", "mkv", "webm"]) @@ -245,8 +290,14 @@ impl AsyncComponent for Tyrolienne { self.video_path = Some(file.path().to_owned()); } } + Message::SetOutFilename(name) => { + self.out_filename = if name.is_empty() { None } else { Some(name) } + } + Message::SetMergeTracks(m) => self.merge_tracks = m, + Message::SetFolder(folder) => { + self.folder = (folder.id != GtkZiplineFolder::NONE_ID).then_some(folder) + } Message::StartTheProcess => { - self.locked = true; let info = self.clone_as_info(); sender.command(|out, shutdown| { Box::pin( @@ -271,21 +322,30 @@ impl AsyncComponent for Tyrolienne { _root: &Self::Root, ) { match message { - ProgressMessage::SetTotal(total) => self.total = total, - ProgressMessage::Progress(prog) => self.progress += prog, - ProgressMessage::Finish(_) | ProgressMessage::Error(_) => { - self.locked = false; + ProgressMessage::SetStep(step) => self.step = step, + ProgressMessage::SetTotal(total) => { + self.progress = 0; + self.total = total; + } + ProgressMessage::AbsProgress(prog) => self.progress = prog, + ProgressMessage::IncProgress(prog) => self.progress += prog, + ProgressMessage::Finish(url) => { + self.step = Step::Waiting; self.progress = 0; self.total = 1; - if let ProgressMessage::Finish(url) = message { - self.toast.emit(ToastInput::Show(url)); - } else if let ProgressMessage::Error(e) = message { - self.dialog.emit(DialogInput::Show { - heading: "An error occurred".into(), - body: e.to_string(), - }); - } + // TODO copy to clipboard here instead, the toast disappears after a short while + self.toast.emit(ToastInput::Show(url)); + } + ProgressMessage::Error(e) => { + self.step = Step::Waiting; + self.progress = 0; + self.total = 1; + + self.dialog.emit(DialogInput::Show { + heading: "An error occurred".into(), + body: e.to_string(), + }); } } } @@ -337,19 +397,28 @@ fn get_config() -> Result { } async fn the_process(app: UploadInfo, sender: &Sender) -> Result { + let Some(ref video_path) = app.video_path else { + bail!("No video given!"); + }; + + sender.emit(ProgressMessage::SetStep(Step::Converting)); + + let video_meta = ffmpeg::get_video_meta(video_path).await?; + sender.emit(ProgressMessage::SetTotal(video_meta.duration_us)); + + let out_path = ffmpeg::convert_video(video_path, sender.clone()).await?; + + sender.emit(ProgressMessage::SetStep(Step::Uploading)); if let Some(folder) = app.folder.as_ref() { tracing::info!("uploading to folder '{}'...", folder.name); } else { tracing::info!("uploading video..."); } - let Some(ref video_path) = app.video_path else { - bail!("No video given!"); - }; - - let res = zipline::upload_file(&app.config, sender, app.folder.as_ref(), video_path).await?; + let res = zipline::upload_file(&app.config, sender, app.folder.as_ref(), &out_path).await?; let zp_file = &res.files[0]; + sender.emit(ProgressMessage::SetStep(Step::Thumbnail)); tracing::info!("recalculating thumbnails..."); zipline::recalc_thumbnails(&app.config).await?; @@ -365,8 +434,10 @@ async fn the_process(app: UploadInfo, sender: &Sender) -> Resul // TODO get w&h from video Ok(format!( - "https://autocompressor.net/av1?v={}&i={}&w=1920&h=1080", + "https://autocompressor.net/av1?v={}&i={}&w={}&h={}", Encoded(&zp_file.url), - Encoded(&thumbnail_url) + Encoded(&thumbnail_url), + video_meta.width, + video_meta.height, )) } diff --git a/src/zipline.rs b/src/zipline.rs index 952fc4a..fab96c9 100644 --- a/src/zipline.rs +++ b/src/zipline.rs @@ -60,7 +60,7 @@ async fn wrap_file(path: &Path, sender: Sender) -> Result let stream = ReaderStream::new(file).map(move |b| { if let Ok(ref bytes) = b { - sender.emit(ProgressMessage::Progress(bytes.len())); + sender.emit(ProgressMessage::IncProgress(bytes.len())); } b });