feat: add initial ffmpeg implementation

This commit is contained in:
uku 2025-05-13 14:22:03 +02:00
parent 1d93482209
commit 8f3c7e4052
Signed by: uku
SSH key fingerprint: SHA256:4P0aN6M8ajKukNi6aPOaX0LacanGYtlfjmN+m/sHY/o
6 changed files with 237 additions and 32 deletions

1
Cargo.lock generated
View file

@ -2405,6 +2405,7 @@ dependencies = [
"reqwest", "reqwest",
"rfd", "rfd",
"serde", "serde",
"serde_json",
"tokio-util", "tokio-util",
"toml", "toml",
"tracing", "tracing",

View file

@ -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"] } 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"] } rfd = { version = "0.15.3", default-features = false, features = ["tokio", "xdg-portal"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
tokio-util = { version = "0.7.15", features = ["io"] } tokio-util = { version = "0.7.15", features = ["io"] }
toml = "0.8.22" toml = "0.8.22"
tracing = "0.1.41" tracing = "0.1.41"

View file

@ -1,8 +1,9 @@
{ {
lib, lib,
craneLib, craneLib,
mold-wrapped, ffmpeg,
libadwaita, libadwaita,
mold-wrapped,
pkg-config, pkg-config,
wrapGAppsHook4, wrapGAppsHook4,
zenity, zenity,
@ -30,7 +31,12 @@ craneLib.buildPackage {
preFixup = '' preFixup = ''
gappsWrapperArgs+=( gappsWrapperArgs+=(
--prefix PATH : ${lib.makeBinPath [ zenity ]} --prefix PATH : ${
lib.makeBinPath [
ffmpeg
zenity
]
}
) )
''; '';

126
src/ffmpeg.rs Normal file
View file

@ -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<StreamInfo>,
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<VideoMeta> {
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::<f64>()?;
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<ProgressMessage>) -> Result<PathBuf> {
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::<usize>()?,
));
}
}
}
Ok(out_path)
}

View file

@ -1,3 +1,4 @@
mod ffmpeg;
mod gobject; mod gobject;
mod relm; mod relm;
mod zipline; mod zipline;
@ -42,6 +43,8 @@ impl Config {
#[derive(Debug)] #[derive(Debug)]
enum Message { enum Message {
OpenFilePicker, OpenFilePicker,
SetOutFilename(String),
SetMergeTracks(bool),
SetFolder(ZiplineFolder), SetFolder(ZiplineFolder),
StartTheProcess, StartTheProcess,
Nothing, Nothing,
@ -49,24 +52,49 @@ enum Message {
#[derive(Debug)] #[derive(Debug)]
enum ProgressMessage { enum ProgressMessage {
SetStep(Step),
SetTotal(usize), SetTotal(usize),
Progress(usize), AbsProgress(usize),
IncProgress(usize),
Finish(String), Finish(String),
Error(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 { struct UploadInfo {
config: Config, config: Config,
video_path: Option<PathBuf>, video_path: Option<PathBuf>,
out_filename: Option<String>,
merge_tracks: bool,
folder: Option<ZiplineFolder>, folder: Option<ZiplineFolder>,
} }
struct Tyrolienne { struct Tyrolienne {
config: Config, config: Config,
locked: bool, step: Step,
progress: usize, progress: usize,
total: usize, total: usize,
video_path: Option<PathBuf>, video_path: Option<PathBuf>,
out_filename: Option<String>,
merge_tracks: bool,
folder: Option<ZiplineFolder>, folder: Option<ZiplineFolder>,
dialog: Controller<Dialog>, dialog: Controller<Dialog>,
toast: Controller<Toast>, toast: Controller<Toast>,
@ -83,6 +111,8 @@ impl Tyrolienne {
fn clone_as_info(&self) -> UploadInfo { fn clone_as_info(&self) -> UploadInfo {
UploadInfo { UploadInfo {
config: self.config.clone(), config: self.config.clone(),
out_filename: self.out_filename.clone(),
merge_tracks: self.merge_tracks,
video_path: self.video_path.clone(), video_path: self.video_path.clone(),
folder: self.folder.clone(), folder: self.folder.clone(),
} }
@ -128,11 +158,26 @@ impl AsyncComponent for Tyrolienne {
set_title: "Video file", set_title: "Video file",
#[watch] #[watch]
set_subtitle: &model.display_video_path(), set_subtitle: &model.display_video_path(),
add_suffix = &gtk::Image {
set_margin_end: 8,
set_icon_name: Some("document-open-symbolic"),
},
connect_activated => Message::OpenFilePicker, 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 { adw::ComboRow {
set_title: "Folder", set_title: "Zipline folder",
set_model: Some(&folder_store), set_model: Some(&folder_store),
set_expression: Some(&folder_expression), set_expression: Some(&folder_expression),
connect_activated[sender] => move |r| { connect_activated[sender] => move |r| {
@ -158,9 +203,10 @@ impl AsyncComponent for Tyrolienne {
gtk::Button { gtk::Button {
#[watch] #[watch]
set_label: if model.locked { "Uploading..." } else { "Send" }, set_label: model.step.button_text(),
#[watch] #[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, connect_clicked => Message::StartTheProcess,
} }
} }
@ -179,10 +225,12 @@ impl AsyncComponent for Tyrolienne {
let model = Tyrolienne { let model = Tyrolienne {
config, config,
locked: false, step: Step::Waiting,
progress: 0, progress: 0,
total: 1, total: 1,
video_path: None, video_path: None,
out_filename: None,
merge_tracks: true,
folder: None, folder: None,
dialog: Dialog::builder() dialog: Dialog::builder()
.launch(toast_overlay.clone()) .launch(toast_overlay.clone())
@ -232,9 +280,6 @@ impl AsyncComponent for Tyrolienne {
) { ) {
match message { match message {
Message::Nothing => {} Message::Nothing => {}
Message::SetFolder(folder) => {
self.folder = (folder.id != GtkZiplineFolder::NONE_ID).then_some(folder)
}
Message::OpenFilePicker => { Message::OpenFilePicker => {
let file = rfd::AsyncFileDialog::new() let file = rfd::AsyncFileDialog::new()
.add_filter("Video file", &["mp4", "mkv", "webm"]) .add_filter("Video file", &["mp4", "mkv", "webm"])
@ -245,8 +290,14 @@ impl AsyncComponent for Tyrolienne {
self.video_path = Some(file.path().to_owned()); 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 => { Message::StartTheProcess => {
self.locked = true;
let info = self.clone_as_info(); let info = self.clone_as_info();
sender.command(|out, shutdown| { sender.command(|out, shutdown| {
Box::pin( Box::pin(
@ -271,21 +322,30 @@ impl AsyncComponent for Tyrolienne {
_root: &Self::Root, _root: &Self::Root,
) { ) {
match message { match message {
ProgressMessage::SetTotal(total) => self.total = total, ProgressMessage::SetStep(step) => self.step = step,
ProgressMessage::Progress(prog) => self.progress += prog, ProgressMessage::SetTotal(total) => {
ProgressMessage::Finish(_) | ProgressMessage::Error(_) => { self.progress = 0;
self.locked = false; 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.progress = 0;
self.total = 1; self.total = 1;
if let ProgressMessage::Finish(url) = message { // TODO copy to clipboard here instead, the toast disappears after a short while
self.toast.emit(ToastInput::Show(url)); self.toast.emit(ToastInput::Show(url));
} else if let ProgressMessage::Error(e) = message { }
self.dialog.emit(DialogInput::Show { ProgressMessage::Error(e) => {
heading: "An error occurred".into(), self.step = Step::Waiting;
body: e.to_string(), 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<Config> {
} }
async fn the_process(app: UploadInfo, sender: &Sender<ProgressMessage>) -> Result<String> { async fn the_process(app: UploadInfo, sender: &Sender<ProgressMessage>) -> Result<String> {
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() { if let Some(folder) = app.folder.as_ref() {
tracing::info!("uploading to folder '{}'...", folder.name); tracing::info!("uploading to folder '{}'...", folder.name);
} else { } else {
tracing::info!("uploading video..."); tracing::info!("uploading video...");
} }
let Some(ref video_path) = app.video_path else { let res = zipline::upload_file(&app.config, sender, app.folder.as_ref(), &out_path).await?;
bail!("No video given!");
};
let res = zipline::upload_file(&app.config, sender, app.folder.as_ref(), video_path).await?;
let zp_file = &res.files[0]; let zp_file = &res.files[0];
sender.emit(ProgressMessage::SetStep(Step::Thumbnail));
tracing::info!("recalculating thumbnails..."); tracing::info!("recalculating thumbnails...");
zipline::recalc_thumbnails(&app.config).await?; zipline::recalc_thumbnails(&app.config).await?;
@ -365,8 +434,10 @@ async fn the_process(app: UploadInfo, sender: &Sender<ProgressMessage>) -> Resul
// TODO get w&h from video // TODO get w&h from video
Ok(format!( 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(&zp_file.url),
Encoded(&thumbnail_url) Encoded(&thumbnail_url),
video_meta.width,
video_meta.height,
)) ))
} }

View file

@ -60,7 +60,7 @@ async fn wrap_file(path: &Path, sender: Sender<ProgressMessage>) -> Result<Part>
let stream = ReaderStream::new(file).map(move |b| { let stream = ReaderStream::new(file).map(move |b| {
if let Ok(ref bytes) = b { if let Ok(ref bytes) = b {
sender.emit(ProgressMessage::Progress(bytes.len())); sender.emit(ProgressMessage::IncProgress(bytes.len()));
} }
b b
}); });