Compare commits

...

6 commits

Author SHA1 Message Date
uku
3315b38806
fix: show loading spinner during startup 2025-05-19 19:26:47 +02:00
uku
dd2616f0e5
fix: conditionally disable audio track merging 2025-05-19 19:24:11 +02:00
uku
0475c52529
fix: abort if ffmpeg process errors 2025-05-19 18:58:06 +02:00
uku
c0049cbb8d
fix: copy url to clipboard directly 2025-05-19 18:55:21 +02:00
uku
a9837b7938
feat: support changing file name 2025-05-19 18:41:29 +02:00
uku
61c759d25e
flake.lock: Update
Flake lock file updates:

• Updated input 'crane':
    'github:ipetkov/crane/dfd9a8dfd09db9aad544c4d3b6c47b12562544a5?narHash=sha256-DdWJLA%2BD5tcmrRSg5Y7tp/qWaD05ATI4Z7h22gd1h7Q%3D' (2025-05-03)
  → 'github:ipetkov/crane/76603d32f18e0e378d9f6335c8fc286413493655?narHash=sha256-Zay3WJdSvC2VQmNqWSVLBOg/1iS/0/Q0c9JOBsB%2B3qw%3D' (2025-05-18)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/3730d8a308f94996a9ba7c7138ede69c1b9ac4ae?narHash=sha256-7%2BpG1I9jvxNlmln4YgnlW4o%2Bw0TZX24k688mibiFDUE%3D' (2025-05-05)
  → 'github:NixOS/nixpkgs/292fa7d4f6519c074f0a50394dbbe69859bb6043?narHash=sha256-GaOZntlJ6gPPbbkTLjbd8BMWaDYafhuuYRNrxCGnPJw%3D' (2025-05-18)
2025-05-19 18:18:38 +02:00
4 changed files with 80 additions and 44 deletions

12
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1746291859, "lastModified": 1747587869,
"narHash": "sha256-DdWJLA+D5tcmrRSg5Y7tp/qWaD05ATI4Z7h22gd1h7Q=", "narHash": "sha256-Zay3WJdSvC2VQmNqWSVLBOg/1iS/0/Q0c9JOBsB+3qw=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "dfd9a8dfd09db9aad544c4d3b6c47b12562544a5", "rev": "76603d32f18e0e378d9f6335c8fc286413493655",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -37,11 +37,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1746461020, "lastModified": 1747542820,
"narHash": "sha256-7+pG1I9jvxNlmln4YgnlW4o+w0TZX24k688mibiFDUE=", "narHash": "sha256-GaOZntlJ6gPPbbkTLjbd8BMWaDYafhuuYRNrxCGnPJw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3730d8a308f94996a9ba7c7138ede69c1b9ac4ae", "rev": "292fa7d4f6519c074f0a50394dbbe69859bb6043",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -3,7 +3,8 @@ use std::{
process::Stdio, process::Stdio,
}; };
use color_eyre::eyre::{ContextCompat, Result}; use color_eyre::eyre::{ContextCompat, Result, bail};
use futures::channel::oneshot;
use relm4::{ use relm4::{
Sender, Sender,
tokio::{ tokio::{
@ -22,9 +23,10 @@ struct FfprobeOut {
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct StreamInfo { #[serde(tag = "codec_type", rename_all = "lowercase")]
width: usize, enum StreamInfo {
height: usize, Video { width: usize, height: usize },
Audio,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@ -36,6 +38,7 @@ struct FormatInfo {
pub struct VideoMeta { pub struct VideoMeta {
pub width: usize, pub width: usize,
pub height: usize, pub height: usize,
pub audio_streams: usize,
pub duration_us: usize, pub duration_us: usize,
} }
@ -61,10 +64,8 @@ pub async fn get_video_meta(path: &Path) -> Result<VideoMeta> {
.args([ .args([
"-v", "-v",
"error", "error",
"-select_streams",
"v:0",
"-show_entries", "-show_entries",
"stream=width,height : format=duration", "stream=width,height,codec_type : format=duration",
"-of", "-of",
"json", "json",
]) ])
@ -76,17 +77,28 @@ pub async fn get_video_meta(path: &Path) -> Result<VideoMeta> {
let str = String::from_utf8(output.stdout)?; let str = String::from_utf8(output.stdout)?;
let output: FfprobeOut = serde_json::from_str(&str)?; let output: FfprobeOut = serde_json::from_str(&str)?;
let stream = output let (width, height) = output
.streams .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")?; .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::<f64>()?; let duration_sec = output.format.duration.parse::<f64>()?;
let duration_us = (duration_sec * 1_000_000.0).ceil() as usize; let duration_us = (duration_sec * 1_000_000.0).ceil() as usize;
Ok(VideoMeta { Ok(VideoMeta {
width: stream.width, width,
height: stream.height, height,
audio_streams,
duration_us, duration_us,
}) })
} }
@ -98,14 +110,22 @@ pub async fn convert_video(
merge_tracks: bool, merge_tracks: bool,
sender: Sender<ProgressMessage>, sender: Sender<ProgressMessage>,
) -> Result<PathBuf> { ) -> Result<PathBuf> {
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 { let codec_args: &[&str] = match out_codec {
Codec::AV1 => &["-c:v", "libsvtav1"], Codec::AV1 => &["-c:v", "libsvtav1"],
Codec::VP9 => &["-c:v", "libvpx-vp9", "-row-mt", "1"], 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 { let merge_args: &[&str] = if merge_tracks {
&["-ac", "2", "-filter_complex", "amerge=inputs=2"] &["-ac", "2", "-filter_complex", "amerge=inputs=2"]
} else { } else {
@ -118,7 +138,7 @@ pub async fn convert_video(
.args(["-c:a", "libopus", "-b:a", "96k"]) .args(["-c:a", "libopus", "-b:a", "96k"])
.args(codec_args) .args(codec_args)
.args(merge_args) .args(merge_args)
.args(["-loglevel", "error", "-progress", "-", "-nostats"]) .args(["-y", "-loglevel", "error", "-progress", "-", "-nostats"])
.arg(&out_path) .arg(&out_path)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.spawn()?; .spawn()?;
@ -127,11 +147,10 @@ pub async fn convert_video(
let mut reader = BufReader::new(stdout).lines(); let mut reader = BufReader::new(stdout).lines();
// make sure the process is actually started and awaited // make sure the process is actually started and awaited
let (tx, rx) = oneshot::channel();
tokio::spawn(async move { tokio::spawn(async move {
child tx.send(child.wait().await)
.wait() .expect("could not send exit status");
.await
.expect("ffmpeg process encountered an error");
}); });
while let Some(line) = reader.next_line().await? { 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) Ok(out_path)
} }

View file

@ -12,6 +12,7 @@ use relm4::{
Component, ComponentController, Controller, RelmApp, Sender, Component, ComponentController, Controller, RelmApp, Sender,
adw::{self, prelude::*}, adw::{self, prelude::*},
gtk::{self, gio}, gtk::{self, gio},
loading_widgets::LoadingWidgets,
prelude::{AsyncComponent, AsyncComponentParts}, prelude::{AsyncComponent, AsyncComponentParts},
tokio, tokio,
}; };
@ -94,6 +95,7 @@ struct Tyrolienne {
step: Step, step: Step,
progress: usize, progress: usize,
total: usize, total: usize,
can_merge: bool,
video_path: Option<PathBuf>, video_path: Option<PathBuf>,
out_filename: Option<String>, out_filename: Option<String>,
out_codec: ffmpeg::Codec, out_codec: ffmpeg::Codec,
@ -116,7 +118,7 @@ impl Tyrolienne {
config: self.config.clone(), config: self.config.clone(),
out_filename: self.out_filename.clone(), out_filename: self.out_filename.clone(),
out_codec: self.out_codec, out_codec: self.out_codec,
merge_tracks: self.merge_tracks, merge_tracks: self.can_merge && self.merge_tracks,
video_path: self.video_path.clone(), video_path: self.video_path.clone(),
folder: self.folder.clone(), folder: self.folder.clone(),
} }
@ -190,7 +192,10 @@ impl AsyncComponent for Tyrolienne {
adw::SwitchRow { adw::SwitchRow {
set_title: "Merge audio tracks", 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())), connect_active_notify[sender] => move |s| sender.input(Message::SetMergeTracks(s.is_active())),
}, },
@ -246,6 +251,7 @@ impl AsyncComponent for Tyrolienne {
step: Step::Waiting, step: Step::Waiting,
progress: 0, progress: 0,
total: 1, total: 1,
can_merge: false,
video_path: None, video_path: None,
out_filename: None, out_filename: None,
out_codec: ffmpeg::Codec::VP9, out_codec: ffmpeg::Codec::VP9,
@ -259,8 +265,6 @@ impl AsyncComponent for Tyrolienne {
.forward(sender.input_sender(), |_| Message::Nothing), .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 { let folders = match zipline::get_folders(&model.config).await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
@ -291,6 +295,21 @@ impl AsyncComponent for Tyrolienne {
AsyncComponentParts { model, widgets } AsyncComponentParts { model, widgets }
} }
fn init_loading_widgets(root: Self::Root) -> Option<LoadingWidgets> {
relm4::view! {
#[local]
root {
#[name(spinner)]
gtk::Spinner {
start: (),
set_halign: gtk::Align::Center,
}
}
}
Some(LoadingWidgets::new(root, spinner))
}
async fn update( async fn update(
&mut self, &mut self,
message: Self::Input, message: Self::Input,
@ -306,6 +325,8 @@ impl AsyncComponent for Tyrolienne {
.await; .await;
if let Some(file) = file { 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()); self.video_path = Some(file.path().to_owned());
} }
} }
@ -353,9 +374,7 @@ impl AsyncComponent for Tyrolienne {
self.step = Step::Waiting; self.step = Step::Waiting;
self.progress = 0; self.progress = 0;
self.total = 1; self.total = 1;
self.toast.emit(ToastInput::ShowAndCopy(url));
// TODO copy to clipboard here instead, the toast disappears after a short while
self.toast.emit(ToastInput::Show(url));
} }
ProgressMessage::Error(e) => { ProgressMessage::Error(e) => {
self.step = Step::Waiting; self.step = Step::Waiting;
@ -459,7 +478,6 @@ async fn the_process(app: UploadInfo, sender: &Sender<ProgressMessage>) -> Resul
.thumbnail_url(&app.config) .thumbnail_url(&app.config)
.ok_or_eyre("could not get thumbnail url")?; .ok_or_eyre("could not get thumbnail url")?;
// TODO get w&h from video
Ok(format!( Ok(format!(
"https://autocompressor.net/av1?v={}&i={}&w={}&h={}", "https://autocompressor.net/av1?v={}&i={}&w={}&h={}",
Encoded(&zp_file.url), Encoded(&zp_file.url),

View file

@ -1,3 +1,4 @@
use arboard::Clipboard;
use relm4::{ use relm4::{
SimpleComponent, SimpleComponent,
adw::{self, prelude::*}, adw::{self, prelude::*},
@ -91,8 +92,7 @@ pub struct Toast {
#[derive(Debug)] #[derive(Debug)]
pub enum ToastInput { pub enum ToastInput {
Show(String), ShowAndCopy(String),
Copy,
Dismiss, Dismiss,
} }
@ -107,8 +107,6 @@ impl SimpleComponent for Toast {
adw::Toast { adw::Toast {
#[watch] #[watch]
set_title: &model.text, set_title: &model.text,
set_button_label: Some("Copy"),
connect_button_clicked => ToastInput::Copy,
connect_dismissed => ToastInput::Dismiss, connect_dismissed => ToastInput::Dismiss,
} }
} }
@ -130,18 +128,14 @@ impl SimpleComponent for Toast {
fn update(&mut self, message: Self::Input, _sender: relm4::ComponentSender<Self>) { fn update(&mut self, message: Self::Input, _sender: relm4::ComponentSender<Self>) {
match message { match message {
ToastInput::Show(text) => { ToastInput::ShowAndCopy(text) => {
self.visible = true; self.visible = true;
self.text = text;
} if let Err(e) = Clipboard::new().and_then(|mut c| c.set_text(text)) {
ToastInput::Copy => {
if let Err(e) =
arboard::Clipboard::new().and_then(|mut c| c.set_text(self.text.clone()))
{
tracing::error!("could not copy url to clipboard: {e}"); tracing::error!("could not copy url to clipboard: {e}");
self.text = "Could not copy".into(); self.text = "Could not copy".into();
} else { } else {
self.visible = false; self.text = "Copied url to clipboard".into();
} }
} }
ToastInput::Dismiss => self.visible = false, ToastInput::Dismiss => self.visible = false,