feat: add initial ffmpeg implementation
This commit is contained in:
parent
1d93482209
commit
8f3c7e4052
6 changed files with 237 additions and 32 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -2405,6 +2405,7 @@ dependencies = [
|
|||
"reqwest",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tracing",
|
||||
|
|
|
@ -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"
|
||||
|
|
10
package.nix
10
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
|
||||
]
|
||||
}
|
||||
)
|
||||
'';
|
||||
|
||||
|
|
126
src/ffmpeg.rs
Normal file
126
src/ffmpeg.rs
Normal 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)
|
||||
}
|
129
src/main.rs
129
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<PathBuf>,
|
||||
out_filename: Option<String>,
|
||||
merge_tracks: bool,
|
||||
folder: Option<ZiplineFolder>,
|
||||
}
|
||||
|
||||
struct Tyrolienne {
|
||||
config: Config,
|
||||
locked: bool,
|
||||
step: Step,
|
||||
progress: usize,
|
||||
total: usize,
|
||||
video_path: Option<PathBuf>,
|
||||
out_filename: Option<String>,
|
||||
merge_tracks: bool,
|
||||
folder: Option<ZiplineFolder>,
|
||||
dialog: Controller<Dialog>,
|
||||
toast: Controller<Toast>,
|
||||
|
@ -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<Config> {
|
|||
}
|
||||
|
||||
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() {
|
||||
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<ProgressMessage>) -> 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,
|
||||
))
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ async fn wrap_file(path: &Path, sender: Sender<ProgressMessage>) -> Result<Part>
|
|||
|
||||
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
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue