Compare commits
6 commits
f7d97f1f2f
...
3315b38806
Author | SHA1 | Date | |
---|---|---|---|
3315b38806 | |||
dd2616f0e5 | |||
0475c52529 | |||
c0049cbb8d | |||
a9837b7938 | |||
61c759d25e |
4 changed files with 80 additions and 44 deletions
12
flake.lock
generated
12
flake.lock
generated
|
@ -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": {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
34
src/main.rs
34
src/main.rs
|
@ -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),
|
||||||
|
|
18
src/relm.rs
18
src/relm.rs
|
@ -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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue