diff --git a/Cargo.lock b/Cargo.lock index bfc48a6eb..1431e7324 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -536,6 +536,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gstreamer-audio-sys" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34258fb53c558c0f41dad194037cbeaabf49d347570df11b8bd1c4897cf7d7c" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + [[package]] name = "gstreamer-base" version = "0.18.0" @@ -563,6 +577,35 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gstreamer-pbutils" +version = "0.18.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330684c49f79775d7acce8bef5a7a7475f02374c9c6cead39ced3ad423fc8ea9" +dependencies = [ + "bitflags", + "glib", + "gstreamer", + "gstreamer-pbutils-sys", + "libc", + "thiserror", +] + +[[package]] +name = "gstreamer-pbutils-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f79839066fbcc6d1a8690b2f85d5cc5cdc0984f36d4054f5cc67a7ad3ab72d" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-audio-sys", + "gstreamer-sys", + "gstreamer-video-sys", + "libc", + "system-deps", +] + [[package]] name = "gstreamer-sys" version = "0.18.0" @@ -701,9 +744,12 @@ dependencies = [ "gdk4-wayland", "gdk4-x11", "gettext-rs", + "glib", "gsettings-macro", "gst-plugin-gif", "gstreamer", + "gstreamer-pbutils", + "gstreamer-video", "gtk4", "libadwaita", "libpulse-binding", diff --git a/Cargo.toml b/Cargo.toml index 3bc8cc266..5422eb242 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,13 @@ gettext-rs = { version = "0.7.0", features = ["gettext-system"] } gtk = { package = "gtk4", version = "0.4.5" } gdk-wayland = { package = "gdk4-wayland", version = "0.4.5" } gdk-x11 = { package = "gdk4-x11", version = "0.4.5" } +glib = { version = "0.15.12", features = ["v2_72"]} adw = { package = "libadwaita", version = "0.2.0-alpha.2", features = ["v1_2"] } gst = { package = "gstreamer", version = "0.18.2" } +gst_video = { package = "gstreamer-video", version = "0.18.2" } +gst_pbutils = { package = "gstreamer-pbutils", version = "0.18.2", features = [ + "v1_20", +] } gst-plugin-gif = "0.8.0" futures-channel = "0.3.19" futures-util = { version = "0.3", default-features = false } diff --git a/README.md b/README.md index 84df62954..591f95d51 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ more efficient or perhaps faster encoding. It is not guaranteed to work on all devices, so it may give errors such as `no element vaapivp8enc` depending on the features and capability of your hardware. +First, you have to install `gstreamer-vaapi` on your system. If Kooha is installed +through Flatpak, it is as simple as running `flatpak install org.freedesktop.Platform.GStreamer.gstreamer-vaapi`. + To enable all the supported drivers and force Kooha to use VAAPI elements, set `GST_VAAPI_ALL_DRIVERS` and `KOOHA_VAAPI` both to 1 respectively. These environment variables are needed for hardware accelerated encoding. diff --git a/build-aux/io.github.seadve.Kooha.Devel.json b/build-aux/io.github.seadve.Kooha.Devel.json index f635424cb..2d14f34dc 100644 --- a/build-aux/io.github.seadve.Kooha.Devel.json +++ b/build-aux/io.github.seadve.Kooha.Devel.json @@ -1,7 +1,7 @@ { "id": "io.github.seadve.Kooha.Devel", "runtime": "org.gnome.Platform", - "runtime-version": "42", + "runtime-version": "43", "sdk": "org.gnome.Sdk", "sdk-extensions": [ "org.freedesktop.Sdk.Extension.rust-stable", @@ -18,7 +18,8 @@ "--talk-name=org.freedesktop.FileManager1", "--env=RUST_BACKTRACE=1", "--env=RUST_LOG=kooha=debug", - "--env=G_MESSAGES_DEBUG=none" + "--env=G_MESSAGES_DEBUG=none", + "--env=GST_DEBUG=3" ], "build-options": { "append-path": "/usr/lib/sdk/llvm14/bin:/usr/lib/sdk/rust-stable/bin", @@ -52,53 +53,15 @@ "builddir": true, "config-opts": [ "-Ddoc=disabled", - "-Dorc=disabled", "-Dnls=disabled", "-Dtests=disabled", - "-Dgobject-cast-checks=disabled", - "-Dglib-asserts=disabled", - "-Dglib-checks=disabled" + "-Dgpl=enabled" ], "sources": [ { "type": "archive", - "url": "https://gstreamer.freedesktop.org/src/gst-plugins-ugly/gst-plugins-ugly-1.18.6.tar.xz", - "sha256": "4969c409cb6a88317d2108b8577108e18623b2333d7b587ae3f39459c70e3a7f" - } - ] - }, - { - "name": "gstreamer-vaapi", - "buildsystem": "meson", - "builddir": true, - "config-opts": [ - "-Ddoc=disabled", - "-Dtests=disabled", - "-Dexamples=disabled", - "-Dwith_encoders=yes" - ], - "sources": [ - { - "type": "archive", - "url": "https://gstreamer.freedesktop.org/src/gstreamer-vaapi/gstreamer-vaapi-1.18.6.tar.xz", - "sha256": "ab6270f1e5e4546fbe6f5ea246d86ca3d196282eb863d46e6cdcc96f867449e0" - } - ] - }, - { - "name": "libadwaita", - "buildsystem": "meson", - "config-opts": [ - "-Dtests=false", - "-Dexamples=false", - "-Dvapi=false", - "-Dintrospection=disabled" - ], - "sources": [ - { - "type": "git", - "url": "https://gitlab.gnome.org/GNOME/libadwaita.git", - "tag": "1.2.beta" + "url": "https://gstreamer.freedesktop.org/src/gst-plugins-ugly/gst-plugins-ugly-1.20.3.tar.xz", + "sha256": "8caa20789a09c304b49cf563d33cca9421b1875b84fcc187e4a385fa01d6aefd" } ] }, diff --git a/data/io.github.seadve.Kooha.gschema.xml.in b/data/io.github.seadve.Kooha.gschema.xml.in index 755b70b5c..a4e4ed67f 100644 --- a/data/io.github.seadve.Kooha.gschema.xml.in +++ b/data/io.github.seadve.Kooha.gschema.xml.in @@ -1,15 +1,6 @@ - - - - - - - - "webm" - diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 347c9bd0c..bf44ad7e2 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -12,6 +12,8 @@ icons/scalable/actions/selection-symbolic.svg icons/scalable/actions/source-pick-symbolic.svg style.css + ui/profile-tile.ui + ui/profile-window.ui ui/shortcuts.ui ui/window.ui diff --git a/data/resources/style.css b/data/resources/style.css index f00aab06f..0524f2bc3 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -28,3 +28,29 @@ button.copy-done { color: @window_fg_color; } } + +profiletile .inner-box { + padding: 24px; +} + +profiletile.selected .inner-box { + padding: 21px; + border-style: solid; + border-width: 3px; + border-color: @accent_color; +} + +profiletile .attribute { + padding: 3px 6px; + border-radius: 99999px; +} + +profiletile .attribute.builtin { + background-color: @accent_bg_color; + color: @accent_fg_color; +} + +profiletile .attribute.unavailable { + background-color: @destructive_bg_color; + color: @destructive_fg_color; +} diff --git a/data/resources/ui/profile-tile.ui b/data/resources/ui/profile-tile.ui new file mode 100644 index 000000000..fb3e60aae --- /dev/null +++ b/data/resources/ui/profile-tile.ui @@ -0,0 +1,157 @@ + + + + + Copy + profile-tile.copy + + + Delete + profile-tile.delete + + + + diff --git a/data/resources/ui/profile-window.ui b/data/resources/ui/profile-window.ui new file mode 100644 index 000000000..a3adf0b20 --- /dev/null +++ b/data/resources/ui/profile-window.ui @@ -0,0 +1,107 @@ + + + + diff --git a/data/resources/ui/window.ui b/data/resources/ui/window.ui index 81e657067..a112f3633 100644 --- a/data/resources/ui/window.ui +++ b/data/resources/ui/window.ui @@ -292,6 +292,13 @@ + +
+ + Edit Profiles… + win.edit-profiles + +
_Delay @@ -316,29 +323,6 @@ 10 - - _Video Format - - WebM - win.video-format - webm - - - MKV - win.video-format - mkv - - - MP4 - win.video-format - mp4 - - - GIF - win.video-format - gif - - _Save to… app.select-saving-location diff --git a/meson.build b/meson.build index 8f953250c..d3355e14f 100644 --- a/meson.build +++ b/meson.build @@ -16,7 +16,7 @@ dependency('gio-2.0', version: '>= 2.66') dependency('gtk4', version: '>= 4.4.0') dependency('libadwaita-1', version: '>= 1.2') dependency('gstreamer-1.0', version: '>= 1.18') -dependency('gstreamer-base-1.0', version: '>= 1.18') +dependency('gstreamer-pbutils-1.0', version: '>= 1.18') dependency('gstreamer-plugins-base-1.0', version: '>= 1.18') dependency('libpulse-mainloop-glib', version: '>= 15.0') dependency('libpulse', version: '>= 15.0') diff --git a/po/POTFILES.in b/po/POTFILES.in index 18662d58a..f22ce719c 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,12 +1,16 @@ data/io.github.seadve.Kooha.desktop.in.in data/io.github.seadve.Kooha.gschema.xml.in data/io.github.seadve.Kooha.metainfo.xml.in.in +data/resources/ui/profile-tile.ui +data/resources/ui/profile-window.ui data/resources/ui/shortcuts.ui data/resources/ui/window.ui src/about.rs src/application.rs src/audio_device.rs src/main.rs +src/profile_tile.rs +src/profile_window.rs src/recording.rs src/settings.rs src/window.rs diff --git a/src/application.rs b/src/application.rs index e639f847d..905ff7960 100644 --- a/src/application.rs +++ b/src/application.rs @@ -11,6 +11,7 @@ use once_cell::unsync::OnceCell; use crate::{ about, config::{APP_ID, PKGDATADIR, PROFILE, VERSION}, + profile_manager::ProfileManager, settings::Settings, utils, window::Window, @@ -22,6 +23,7 @@ mod imp { #[derive(Debug, Default)] pub struct Application { pub(super) window: OnceCell>, + pub(super) profile_manager: OnceCell, pub(super) settings: Settings, } @@ -91,6 +93,10 @@ impl Application { main_window } + pub fn profile_manager(&self) -> &ProfileManager { + self.imp().profile_manager.get_or_init(ProfileManager::new) + } + pub fn send_record_success_notification(&self, recording_file: &gio::File) { // Translators: This is a message that the user will see when the recording is finished. let notification = gio::Notification::new(&gettext("Screencast recorded")); diff --git a/src/element_factory_profile.rs b/src/element_factory_profile.rs new file mode 100644 index 000000000..746654196 --- /dev/null +++ b/src/element_factory_profile.rs @@ -0,0 +1,293 @@ +use anyhow::{anyhow, ensure, Context, Result}; +use gst::prelude::*; +use gtk::glib::{ + self, + translate::{ToGlibPtr, UnsafeFrom}, + ToSendValue, +}; +use once_cell::unsync::OnceCell; + +use std::cell::RefCell; + +pub trait EncodingProfileExtManual { + fn set_element_properties(&self, element_properties: gst::Structure); +} + +impl> EncodingProfileExtManual for P { + fn set_element_properties(&self, element_properties: gst::Structure) { + unsafe { + gst_pbutils::ffi::gst_encoding_profile_set_element_properties( + self.as_ref().to_glib_none().0, + element_properties.into_ptr(), + ); + } + } +} + +#[derive(Debug, Clone, glib::Boxed)] +#[boxed_type(name = "KoohaElementFactoryProfile", nullable)] +pub struct ElementFactoryProfile { + structure: gst::Structure, + factory: OnceCell, + format: OnceCell, + format_fields: RefCell>>, +} + +impl PartialEq for ElementFactoryProfile { + fn eq(&self, other: &Self) -> bool { + self.structure == other.structure && self.format == other.format + } +} + +impl Eq for ElementFactoryProfile {} + +impl ElementFactoryProfile { + pub fn new(factory_name: &str) -> Self { + Self::builder(factory_name).build() + } + + pub fn builder(factory_name: &str) -> ElementFactoryProfileBuilder<'_> { + ElementFactoryProfileBuilder::new(factory_name) + } + + pub fn factory_name(&self) -> &str { + self.structure.name() + } + + pub fn factory(&self) -> Result<&gst::ElementFactory> { + self.factory + .get_or_try_init(|| find_element_factory(self.factory_name())) + } + + pub fn format(&self) -> Result<&gst::Caps> { + if let Some(caps) = self.format.get() { + return Ok(caps); + } + + let factory = self.factory()?; + let format = profile_format_from_factory(factory, self.format_fields.take().unwrap())?; + Ok(self.format.try_insert(format).unwrap()) + } + + pub fn element_properties(&self) -> gst::Structure { + gst::Structure::builder("element-properties-map") + .field("map", gst::List::from(vec![self.structure.to_send_value()])) + .build() + } +} + +fn profile_format_from_factory( + factory: &gst::ElementFactory, + values: Vec<(String, glib::SendValue)>, +) -> Result { + let factory_name = factory.name(); + + ensure!( + factory.has_type(gst::ElementFactoryType::ENCODER | gst::ElementFactoryType::MUXER), + "Factory `{}` must be an encoder or muxer to be used in a profile", + factory_name + ); + + for template in factory.static_pad_templates() { + if template.direction() == gst::PadDirection::Src { + let template_caps = template.caps(); + if let Some(structure) = template_caps.structure(0) { + let mut structure = structure.to_owned(); + + for (f, v) in values { + structure.set_value(&f, v); + } + + let mut caps = gst::Caps::new_empty(); + caps.get_mut().unwrap().append_structure(structure); + return Ok(caps); + } + } + } + + Err(anyhow!( + "Failed to find profile format for factory `{}`", + factory_name + )) +} + +fn find_element_factory(factory_name: &str) -> Result { + gst::ElementFactory::find(factory_name) + .ok_or_else(|| anyhow!("`{}` factory not found", factory_name)) +} + +pub struct ElementFactoryProfileBuilder<'a> { + factory_name: &'a str, + element_properties: Vec<(&'a str, glib::SendValue)>, + format_fields: Vec<(&'a str, glib::SendValue)>, +} + +impl<'a> ElementFactoryProfileBuilder<'a> { + pub fn new(factory_name: &'a str) -> Self { + Self { + factory_name, + element_properties: Vec::new(), + format_fields: Vec::new(), + } + } + + #[allow(clippy::needless_pass_by_value)] + pub fn format_field(mut self, field: &'a str, value: T) -> Self + where + T: ToSendValue + Sync, + { + self.format_fields.push((field, value.to_send_value())); + self + } + + #[allow(clippy::needless_pass_by_value)] + pub fn property(mut self, property_name: &'a str, value: T) -> Self + where + T: ToSendValue + Sync, + { + self.element_properties + .push((property_name, value.to_send_value())); + self + } + + /// Parses the given string into a property of element from the + /// given `factory_name` with type based on the property's param spec. + /// + /// This works similar to `GObjectExtManualGst::set_property_from_str`. + /// + /// Note: The property will not be set if any of `factory_name`, `property_name` + /// or `string` is invalid. + pub fn property_from_str(mut self, property_name: &'a str, value_string: &str) -> Self { + match value_from_str(self.factory_name, property_name, value_string) { + Ok(value) => self.element_properties.push((property_name, value)), + Err(err) => tracing::warn!( + "Failed to set property `{}` to `{}`: {:?}", + property_name, + value_string, + err + ), + } + + self + } + + pub fn build(self) -> ElementFactoryProfile { + ElementFactoryProfile { + structure: gst::Structure::from_iter(self.factory_name, self.element_properties), + factory: OnceCell::new(), + format: OnceCell::new(), + format_fields: RefCell::new(Some( + self.format_fields + .iter() + .map(|(k, v)| (str::to_string(k), v.to_send_value())) + .collect(), + )), + } + } +} + +fn value_from_str( + factory_name: &str, + property_name: &str, + value_string: &str, +) -> Result { + let element_type = gst::ElementFactory::find(factory_name) + .ok_or_else(|| anyhow!("Failed to find factory with name `{}`", factory_name))? + .load() + .with_context(|| anyhow!("Failed to load factory with name `{}`", factory_name))? + .element_type(); + let pspec = glib::object::ObjectClass::from_type(element_type) + .ok_or_else(|| anyhow!("Failed to create object class from type `{}`", element_type))? + .find_property(property_name) + .ok_or_else(|| { + glib::bool_error!( + "Property `{}` not found on type `{}`", + property_name, + element_type + ) + })?; + let value = unsafe { + glib::SendValue::unsafe_from( + glib::Value::deserialize_with_pspec(value_string, &pspec)?.into_raw(), + ) + }; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn element_properties() { + let profile = ElementFactoryProfile::new("vp8enc"); + let element_properties = profile.element_properties(); + assert_eq!(element_properties.name(), "element-properties-map"); + assert_eq!( + element_properties + .get::("map") + .unwrap() + .get(0) + .unwrap() + .get::(), + Ok(profile.structure) + ); + } + + #[test] + fn test_profile_format_from_factory() { + #[track_caller] + fn profile_format_from_factory_name(factory_name: &str) -> Result { + profile_format_from_factory(&find_element_factory(factory_name).unwrap(), Vec::new()) + } + + assert!(profile_format_from_factory_name("vp8enc") + .unwrap() + .can_intersect(&gst::Caps::builder("video/x-vp8").build())); + assert!(profile_format_from_factory_name("opusenc") + .unwrap() + .can_intersect(&gst::Caps::builder("audio/x-opus").build())); + assert!(profile_format_from_factory_name("matroskamux") + .unwrap() + .can_intersect(&gst::Caps::builder("video/x-matroska").build())); + assert!(!profile_format_from_factory_name("matroskamux") + .unwrap() + .can_intersect(&gst::Caps::builder("video/x-vp8").build()),); + assert_eq!( + profile_format_from_factory_name("audioconvert") + .unwrap_err() + .to_string(), + "Factory `audioconvert` must be an encoder or muxer to be used in a profile" + ); + } + + #[test] + fn builder() { + gst::init().unwrap(); + + let profile = ElementFactoryProfile::builder("vp8enc") + .property("cq-level", 13) + .property("resize-allowed", false) + .build(); + assert_eq!(profile.structure.n_fields(), 2); + assert_eq!(profile.structure.name(), "vp8enc"); + assert_eq!(profile.structure.get::("cq-level").unwrap(), 13); + assert!(!profile.structure.get::("resize-allowed").unwrap()); + } + + #[test] + fn builder_field_from_str() { + gst::init().unwrap(); + + let profile = ElementFactoryProfile::builder("vp8enc") + .property("threads", 16) + .property_from_str("keyframe-mode", "disabled") + .build(); + assert_eq!(profile.structure.n_fields(), 2); + assert_eq!(profile.structure.name(), "vp8enc"); + assert_eq!(profile.structure.get::("threads").unwrap(), 16); + + let keyframe_mode_value = profile.structure.value("keyframe-mode").unwrap(); + assert!(format!("{:?}", keyframe_mode_value).starts_with("(GstVPXEncKfMode)")); + } +} diff --git a/src/main.rs b/src/main.rs index 683dea10f..8f4aaa2d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,8 +26,13 @@ mod area_selector; mod audio_device; mod cancelled; mod config; +mod element_factory_profile; mod help; -mod pipeline_builder; +mod pipeline; +mod profile; +mod profile_manager; +mod profile_tile; +mod profile_window; mod recording; mod screencast_session; mod settings; diff --git a/src/pipeline.rs b/src/pipeline.rs new file mode 100644 index 000000000..07a37f376 --- /dev/null +++ b/src/pipeline.rs @@ -0,0 +1,409 @@ +use anyhow::{bail, Context, Ok, Result}; +use gst::prelude::*; +use gtk::graphene::{Rect, Size}; + +use std::{ + ffi::OsStr, + os::unix::io::RawFd, + path::{Path, PathBuf}, +}; + +use crate::{profile::Profile, screencast_session::Stream, utils}; + +// TODO +// * Do we need restrictions? +// * Can we drop filter elements (videorate, videoconvert, videoscale, audioconvert) and let encodebin handle it? +// * Add tests + +#[derive(Debug)] +struct SelectAreaContext { + pub coords: Rect, + pub screen_size: Size, +} + +#[derive(Debug)] +#[must_use] +pub struct PipelineBuilder { + saving_location: PathBuf, + framerate: u32, + profile: Profile, + fd: RawFd, + streams: Vec, + speaker_source: Option, + mic_source: Option, + select_area_context: Option, +} + +impl PipelineBuilder { + pub fn new( + saving_location: &Path, + framerate: u32, + profile: Profile, + fd: RawFd, + streams: Vec, + ) -> Self { + Self { + saving_location: saving_location.to_path_buf(), + framerate, + profile, + fd, + streams, + speaker_source: None, + mic_source: None, + select_area_context: None, + } + } + + pub fn speaker_source(&mut self, speaker_source: String) -> &mut Self { + self.speaker_source = Some(speaker_source); + self + } + + pub fn mic_source(&mut self, mic_source: String) -> &mut Self { + self.mic_source = Some(mic_source); + self + } + + pub fn select_area_context(&mut self, coords: Rect, screen_size: Size) -> &mut Self { + self.select_area_context = Some(SelectAreaContext { + coords, + screen_size, + }); + self + } + + pub fn build(&self) -> Result { + let file_path = new_recording_path(&self.saving_location, self.profile.file_extension()); + + let encodebin = element_factory_make("encodebin")?; + encodebin.set_property("profile", &self.profile.to_encoding_profile()?); + let queue = element_factory_make("queue")?; + let filesink = element_factory_make_named("filesink", Some("filesink"))?; + filesink.set_property( + "location", + file_path + .to_str() + .context("Could not convert file path to string")?, + ); + + let pipeline = gst::Pipeline::new(None); + pipeline.add_many(&[&encodebin, &queue, &filesink])?; + gst::Element::link_many(&[&encodebin, &queue, &filesink])?; + + tracing::debug!( + file_path = %file_path.display(), + profile_name = ?self.profile.name(), + framerate = self.framerate, + stream_len = self.streams.len(), + streams = ?self.streams, + speaker_source = ?self.speaker_source, + mic_source = ?self.mic_source, + ); + + let videosrc_bin = match self.streams.len() { + 0 => bail!("Found no streams"), + 1 => single_stream_pipewiresrc_bin( + self.fd, + self.streams.get(0).unwrap(), + self.framerate, + self.select_area_context.as_ref(), + )?, + _ => { + if self.select_area_context.is_some() { + bail!("Select area is not supported for multiple streams"); + } + + multi_stream_pipewiresrc_bin(self.fd, &self.streams, self.framerate)? + } + }; + + pipeline.add(&videosrc_bin)?; + videosrc_bin.static_pad("src").unwrap().link( + &encodebin + .request_pad_simple("video_%u") + .context("Failed to request video_%u pad from encodebin")?, + )?; + + [&self.speaker_source, &self.mic_source] + .iter() + .filter_map(|d| d.as_ref()) // Filter out None + .try_for_each(|device_name| { + let pulsesrc_bin = pulsesrc_bin(device_name)?; + pipeline.add(&pulsesrc_bin)?; + pulsesrc_bin.static_pad("src").unwrap().link( + &encodebin + .request_pad_simple("audio_%u") + .context("Failed to request audio_%u pad from encodebin")?, + )?; + Ok(()) + })?; + + if tracing::enabled!(tracing::Level::DEBUG) { + let encodebin_elements = encodebin + .downcast::() + .unwrap() + .iterate_recurse() + .into_iter() + .map(|element| { + let element = element.unwrap(); + let name = element + .factory() + .map_or_else(|| element.name(), |f| f.name()); + if name == "capsfilter" { + element.property::("caps").to_string() + } else { + name.to_string() + } + }) + .collect::>(); + tracing::debug!(?encodebin_elements); + } + + Ok(pipeline) + } +} + +/// Helper function for more helpful error messages when failing +/// to make an element. +fn element_factory_make(factory_name: &str) -> Result { + element_factory_make_named(factory_name, None) +} + +/// Helper function for more helpful error messages when failing +/// to make an element. +fn element_factory_make_named( + factory_name: &str, + element_name: Option<&str>, +) -> Result { + gst::ElementFactory::make(factory_name, element_name) + .with_context(|| format!("Failed to make element `{}`", factory_name)) +} + +fn pipewiresrc_with_default(fd: RawFd, path: &str) -> Result { + let src = element_factory_make("pipewiresrc")?; + src.set_property("fd", &fd); + src.set_property("path", path); + src.set_property("do-timestamp", true); + src.set_property("keepalive-time", 1000); + src.set_property("resend-last", true); + Ok(src) +} + +fn videoconvert_with_default() -> Result { + let conv = element_factory_make("videoconvert")?; + conv.set_property("chroma-mode", gst_video::VideoChromaMode::None); + conv.set_property("dither", gst_video::VideoDitherMethod::None); + conv.set_property("matrix-mode", gst_video::VideoMatrixMode::OutputOnly); + conv.set_property("n-threads", utils::ideal_thread_count()); + Ok(conv) +} + +/// Create a videocrop element that computes the crop from the given coordinates +/// and size. +fn videocrop_compute( + stream_width: i32, + stream_height: i32, + context: &SelectAreaContext, +) -> Result { + let actual_screen = context.screen_size; + + let scale_factor = stream_width as f32 / actual_screen.width(); + let coords = context.coords.scale(scale_factor, scale_factor); + + let top_crop = coords.y(); + let left_crop = coords.x(); + let right_crop = stream_width as f32 - (coords.width() + coords.x()); + let bottom_crop = stream_height as f32 - (coords.height() + coords.y()); + + tracing::debug!(top_crop, left_crop, right_crop, bottom_crop); + + // x264enc requires even resolution. + let crop = element_factory_make("videocrop")?; + crop.set_property("top", round_to_even_f32(top_crop)); + crop.set_property("left", round_to_even_f32(left_crop)); + crop.set_property("right", round_to_even_f32(right_crop)); + crop.set_property("bottom", round_to_even_f32(bottom_crop)); + Ok(crop) +} + +/// Creates a bin with a src pad for multiple pipewire streams. +/// +/// pipewiresrc1 -> videorate -> | +/// | -> compositor -> videoconvert -> queue +/// pipewiresrc2 -> videorate -> | +fn multi_stream_pipewiresrc_bin(fd: i32, streams: &[Stream], framerate: u32) -> Result { + let bin = gst::Bin::new(None); + + let compositor = element_factory_make("compositor")?; + let videoconvert = videoconvert_with_default()?; + let queue = element_factory_make("queue")?; + + bin.add_many(&[&compositor, &videoconvert, &queue])?; + gst::Element::link_many(&[&compositor, &videoconvert, &queue])?; + + let videorate_filter = gst::Caps::builder("video/x-raw") + .field("framerate", gst::Fraction::new(framerate as i32, 1)) + .build(); + + let mut last_pos = 0; + for stream in streams { + let pipewiresrc = pipewiresrc_with_default(fd, &stream.node_id().to_string())?; + let videorate = element_factory_make("videorate")?; + let videorate_capsfilter = element_factory_make("capsfilter")?; + videorate_capsfilter.set_property("caps", &videorate_filter); + + bin.add_many(&[&pipewiresrc, &videorate, &videorate_capsfilter])?; + gst::Element::link_many(&[&pipewiresrc, &videorate, &videorate_capsfilter])?; + + let compositor_sink_pad = compositor + .request_pad_simple("sink_%u") + .context("Failed to request sink_%u pad from compositor")?; + compositor_sink_pad.set_property("xpos", last_pos); + videorate_capsfilter + .static_pad("src") + .unwrap() + .link(&compositor_sink_pad)?; + + let stream_width = stream.size().unwrap().0; + last_pos += stream_width; + } + + let queue_pad = queue.static_pad("src").unwrap(); + bin.add_pad(&gst::GhostPad::with_target(Some("src"), &queue_pad)?)?; + + Ok(bin) +} + +/// Creates a bin with a src pad for a single pipewire stream. +/// +/// No selection: +/// pipewiresrc -> videconvert -> videorate -> queue +/// +/// Has selection: +/// pipewiresrc -> videconvert -> videorate -> videoscale -> videocrop -> queue +fn single_stream_pipewiresrc_bin( + fd: RawFd, + stream: &Stream, + framerate: u32, + select_area_context: Option<&SelectAreaContext>, +) -> Result { + let bin = gst::Bin::new(None); + + let pipewiresrc = pipewiresrc_with_default(fd, &stream.node_id().to_string())?; + let videoconvert = videoconvert_with_default()?; + let videorate = element_factory_make("videorate")?; + let queue = element_factory_make("queue")?; + + bin.add_many(&[&pipewiresrc, &videoconvert, &videorate, &queue])?; + gst::Element::link_many(&[&pipewiresrc, &videoconvert, &videorate])?; + + let videorate_filter = gst::Caps::builder("video/x-raw") + .field("framerate", gst::Fraction::new(framerate as i32, 1)) + .build(); + + if let Some(context) = select_area_context { + let (stream_width, stream_height) = stream.size().context("Stream has no size")?; + + let videoscale = element_factory_make("videoscale")?; + let videocrop = videocrop_compute(stream_width, stream_height, context)?; + + // x264enc requires even resolution. + let videoscale_filter = gst::Caps::builder("video/x-raw") + .field("width", round_to_even(stream_width)) + .field("height", round_to_even(stream_height)) + .build(); + + bin.add_many(&[&videoscale, &videocrop])?; + videorate.link_filtered(&videoscale, &videorate_filter)?; + videoscale.link_filtered(&videocrop, &videoscale_filter)?; + gst::Element::link_many(&[&videocrop, &queue])?; + } else { + videorate.link_filtered(&queue, &videorate_filter)?; + } + + let queue_pad = queue.static_pad("src").unwrap(); + bin.add_pad(&gst::GhostPad::with_target(Some("src"), &queue_pad)?)?; + + Ok(bin) +} + +/// Creates a bin with a src pad for a pulse audio device +/// +/// pulsesrc -> audioconvert -> queue +fn pulsesrc_bin(device_name: &str) -> Result { + let bin = gst::Bin::new(None); + + let pulsesrc = element_factory_make("pulsesrc")?; + pulsesrc.set_property("device", device_name); + let audioconvert = element_factory_make("audioconvert")?; + let queue = element_factory_make("queue")?; + + bin.add_many(&[&pulsesrc, &audioconvert, &queue])?; + gst::Element::link_many(&[&pulsesrc, &audioconvert, &queue])?; + + let queue_pad = queue.static_pad("src").unwrap(); + bin.add_pad(&gst::GhostPad::with_target(Some("src"), &queue_pad)?)?; + + Ok(bin) +} + +fn round_to_even(number: i32) -> i32 { + number / 2 * 2 +} + +fn round_to_even_f32(number: f32) -> i32 { + number as i32 / 2 * 2 +} + +fn new_recording_path(saving_location: &Path, extension: impl AsRef) -> PathBuf { + let file_name = glib::DateTime::now_local() + .expect("You are somehow on year 9999") + .format("Kooha-%F-%H-%M-%S") + .expect("Invalid format string"); + + let mut path = saving_location.join(file_name); + path.set_extension(extension); + + path +} + +#[cfg(test)] +mod test { + use super::*; + + macro_rules! assert_even { + ($number:expr) => { + assert_eq!($number % 2, 0) + }; + } + + #[test] + fn odd_round_to_even() { + assert_even!(round_to_even(5)); + assert_even!(round_to_even(101)); + } + + #[test] + fn odd_round_to_even_f32() { + assert_even!(round_to_even_f32(3.0)); + assert_even!(round_to_even_f32(99.0)); + } + + #[test] + fn even_round_to_even() { + assert_even!(round_to_even(50)); + assert_even!(round_to_even(4)); + } + + #[test] + fn even_round_to_even_f32() { + assert_even!(round_to_even_f32(300.0)); + assert_even!(round_to_even_f32(6.0)); + } + + #[test] + fn float_round_to_even_f32() { + assert_even!(round_to_even_f32(5.3)); + assert_even!(round_to_even_f32(2.9)); + } +} diff --git a/src/pipeline_builder.rs b/src/pipeline_builder.rs deleted file mode 100644 index 15e71f5bf..000000000 --- a/src/pipeline_builder.rs +++ /dev/null @@ -1,369 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use gtk::{ - glib, - graphene::{Rect, Size}, - prelude::*, -}; - -use std::{ - cmp, env, - os::unix::io::RawFd, - path::{Path, PathBuf}, -}; - -use crate::{screencast_session::Stream, settings::VideoFormat}; - -const MAX_THREAD_COUNT: u32 = 64; -const GIF_DEFAULT_FRAMERATE: u32 = 15; - -#[derive(Debug)] -#[must_use] -pub struct PipelineBuilder { - file_path: PathBuf, - framerate: u32, - format: VideoFormat, - fd: RawFd, - streams: Vec, - speaker_source: Option, - mic_source: Option, - coordinates: Option, - actual_screen: Option, -} - -impl PipelineBuilder { - pub fn new( - file_path: &Path, - framerate: u32, - format: VideoFormat, - fd: RawFd, - streams: Vec, - ) -> Self { - Self { - file_path: file_path.to_path_buf(), - framerate, - format, - fd, - streams, - speaker_source: None, - mic_source: None, - coordinates: None, - actual_screen: None, - } - } - - pub fn speaker_source(&mut self, speaker_source: String) -> &mut Self { - self.speaker_source = Some(speaker_source); - self - } - - pub fn mic_source(&mut self, mic_source: String) -> &mut Self { - self.mic_source = Some(mic_source); - self - } - - pub fn coordinates(&mut self, coordinates: Rect) -> &mut Self { - self.coordinates = Some(coordinates); - self - } - - pub fn actual_screen(&mut self, actual_screen: Size) -> &mut Self { - self.actual_screen = Some(actual_screen); - self - } - - pub fn build(self) -> Result { - let pipeline_string = PipelineAssembler::from_builder(self).assemble()?; - tracing::debug!(?pipeline_string); - - gst::parse_launch_full(&pipeline_string, None, gst::ParseFlags::FATAL_ERRORS) - .map(|element| element.downcast().unwrap()) - .with_context(|| { - format!( - "Failed to parse string into pipeline. string: {}", - pipeline_string - ) - }) - } -} - -struct PipelineAssembler { - builder: PipelineBuilder, -} - -impl PipelineAssembler { - pub fn from_builder(builder: PipelineBuilder) -> Self { - Self { builder } - } - - pub fn assemble(&self) -> Result { - let file_path = self - .builder - .file_path - .to_str() - .ok_or_else(|| anyhow!("Could not convert file_path to string."))?; - - let pipeline_elements = vec![ - self.compositor(), - Some("queue name=queue0".to_string()), - Some("videorate".to_string()), - Some(format!("video/x-raw, framerate={}/1", self.framerate())), - self.videoscale(), - self.videocrop(), - Some("videoconvert chroma-mode=GST_VIDEO_CHROMA_MODE_NONE dither=GST_VIDEO_DITHER_NONE matrix-mode=GST_VIDEO_MATRIX_MODE_OUTPUT_ONLY n-threads=%T".to_string()), - Some("queue".to_string()), - Some(self.videoenc()), - Some("queue".to_string()), - self.muxer(), - Some(format!("filesink name=filesink location=\"{}\"", file_path)), - ]; - - let pipeline_string = pipeline_elements - .into_iter() - .flatten() - .collect::>() - .join(" ! "); - - Ok([ - pipeline_string, - self.pipewiresrc(), - self.pulsesrc().unwrap_or_default(), - ] - .join(" ") - .replace("%T", &ideal_thread_count().to_string())) - } - - fn compositor(&self) -> Option { - if self.has_single_stream() { - return None; - } - - // This allows us to place the videos side by side with each other, without overlaps. - let mut current_pos = 0; - let compositor_pads: Vec = self - .streams() - .iter() - .enumerate() - .map(|(sink_num, stream)| { - let pad = format!("sink_{}::xpos={}", sink_num, current_pos); - let stream_width = stream.size().unwrap().0; - current_pos += stream_width; - pad - }) - .collect(); - - Some(format!( - "compositor name=comp {}", - compositor_pads.join(" ") - )) - } - - fn pipewiresrc(&self) -> String { - if self.has_single_stream() { - // If there is a single stream, connect pipewiresrc directly to queue0. - let node_id = self.streams()[0].node_id(); - return format!("pipewiresrc fd={} path={} do-timestamp=true keepalive-time=1000 resend-last=true ! video/x-raw ! queue0.", self.fd(), node_id); - } - - let pipewiresrc_list: Vec = self.streams().iter().map(|stream| { - let node_id = stream.node_id(); - format!("pipewiresrc fd={} path={} do-timestamp=true keepalive-time=1000 resend-last=true ! video/x-raw ! comp.", self.fd(), node_id) - }).collect(); - - pipewiresrc_list.join(" ") - } - - fn pulsesrc(&self) -> Option { - let audioenc = match self.video_format() { - VideoFormat::Webm | VideoFormat::Mkv | VideoFormat::Mp4 => "opusenc", - VideoFormat::Gif => return None, - }; - - match (self.speaker_source(), self.mic_source()) { - (Some(speaker_source), Some(mic_source)) => { - Some(format!("pulsesrc device=\"{}\" ! queue ! audiomixer name=mix ! {} ! queue ! mux. pulsesrc device=\"{}\" ! queue ! mix.", - speaker_source, - audioenc, - mic_source, - )) - } - (Some(speaker_source), None) => { - Some(format!( - "pulsesrc device=\"{}\" ! {} ! queue ! mux.", - speaker_source, audioenc - )) - } - (None, Some(mic_source)) => { - Some(format!( - "pulsesrc device=\"{}\" ! {} ! queue ! mux.", - mic_source, audioenc - )) - } - (None, None) => None, - } - } - - fn videoscale(&self) -> Option { - if self.builder.coordinates.is_some() { - // We could freely get the first stream because screencast portal won't allow multiple - // sources selection if it is selection mode. Thus, there will be always single stream - // present when we have coordinates. (The same applies with videocrop). - let (width, height) = self.streams()[0].size().unwrap(); - - Some(format!( - "videoscale ! video/x-raw, width={}, height={}", - round_to_even(width), - round_to_even(height) - )) - } else { - None - } - } - - fn videocrop(&self) -> Option { - self.builder.coordinates.map(|ref coords| { - let stream = &self.streams()[0]; - - let actual_screen = self.builder.actual_screen.as_ref().unwrap(); - let (stream_width, stream_height) = stream.size().unwrap(); - - let scale_factor = stream_width as f32 / actual_screen.width(); - let coords = coords.scale(scale_factor, scale_factor); - - let top_crop = coords.y(); - let left_crop = coords.x(); - let right_crop = stream_width as f32 - (coords.width() + coords.x()); - let bottom_crop = stream_height as f32 - (coords.height() + coords.y()); - - // It is a requirement for x264enc to have even resolution. - format!( - "videocrop top={} left={} right={} bottom={}", - round_to_even_f32(top_crop), - round_to_even_f32(left_crop), - round_to_even_f32(right_crop), - round_to_even_f32(bottom_crop) - ) - }) - } - - fn videoenc(&self) -> String { - // TODO consider using encodebin - - let value = env::var("KOOHA_VAAPI").unwrap_or_default(); - let is_use_vaapi = value == "1"; - - tracing::debug!(?is_use_vaapi); - - if is_use_vaapi { - match self.video_format() { - VideoFormat::Webm | VideoFormat::Mkv => "vaapivp8enc", // FIXME Improve pipelines - VideoFormat::Mp4 => "vaapih264enc max-qp=17 ! h264parse", - VideoFormat::Gif => "gifenc repeat=-1 speed=30", // FIXME This doesn't really use vaapi - } - } else { - match self.video_format() { - VideoFormat::Webm | VideoFormat::Mkv => "vp8enc max_quantizer=17 cpu-used=16 cq_level=13 deadline=1 static-threshold=100 keyframe-mode=disabled buffer-size=20000 threads=%T", - VideoFormat::Mp4 => "x264enc qp-max=17 speed-preset=superfast threads=%T ! video/x-h264, profile=baseline", - VideoFormat::Gif => "gifenc repeat=-1 speed=30", - } - }.to_string() - } - - fn muxer(&self) -> Option { - let video_format = self.video_format(); - - let muxer = match video_format { - VideoFormat::Webm => "webmmux", - VideoFormat::Mkv => "matroskamux", - VideoFormat::Mp4 => "mp4mux", - VideoFormat::Gif => return None, - }; - - Some(format!("{} name=mux", muxer)) - } - - fn video_format(&self) -> VideoFormat { - self.builder.format - } - - fn framerate(&self) -> u32 { - if self.video_format() == VideoFormat::Gif { - return GIF_DEFAULT_FRAMERATE; - } - - self.builder.framerate - } - - fn speaker_source(&self) -> Option<&str> { - self.builder.speaker_source.as_deref() - } - - fn mic_source(&self) -> Option<&str> { - self.builder.mic_source.as_deref() - } - - fn fd(&self) -> i32 { - self.builder.fd - } - - fn streams(&self) -> &Vec { - &self.builder.streams - } - - fn has_single_stream(&self) -> bool { - self.streams().len() == 1 - } -} - -fn round_to_even_f32(number: f32) -> i32 { - number as i32 / 2 * 2 -} - -fn round_to_even(number: i32) -> i32 { - number / 2 * 2 -} - -fn ideal_thread_count() -> u32 { - let num_processors = glib::num_processors(); - cmp::min(num_processors, MAX_THREAD_COUNT) -} - -#[cfg(test)] -mod test { - use super::*; - - macro_rules! assert_even { - ($number:expr) => { - assert_eq!($number % 2, 0) - }; - } - - #[test] - fn odd_round_to_even() { - assert_even!(round_to_even(5)); - assert_even!(round_to_even(101)); - } - - #[test] - fn odd_round_to_even_f32() { - assert_even!(round_to_even_f32(3.0)); - assert_even!(round_to_even_f32(99.0)); - } - - #[test] - fn even_round_to_even() { - assert_even!(round_to_even(50)); - assert_even!(round_to_even(4)); - } - - #[test] - fn even_round_to_even_f32() { - assert_even!(round_to_even_f32(300.0)); - assert_even!(round_to_even_f32(6.0)); - } - - #[test] - fn float_round_to_even_f32() { - assert_even!(round_to_even_f32(5.3)); - assert_even!(round_to_even_f32(2.9)); - } -} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 000000000..a45dc676e --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,336 @@ +use anyhow::{anyhow, ensure, Result}; +use gst_pbutils::prelude::*; +use gtk::{glib, subclass::prelude::*}; +use once_cell::unsync::OnceCell; + +use std::cell::{Cell, RefCell}; + +use crate::element_factory_profile::{ElementFactoryProfile, EncodingProfileExtManual}; + +mod imp { + use super::*; + use once_cell::sync::Lazy; + + #[derive(Debug, Default)] + pub struct Profile { + pub(super) is_builtin: OnceCell, + pub(super) name: RefCell, + pub(super) file_extension: RefCell, + pub(super) muxer_profile: RefCell>, + pub(super) video_encoder_profile: RefCell>, + pub(super) audio_encoder_profile: RefCell>, + pub(super) is_available: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for Profile { + const NAME: &'static str = "KoohaProfile"; + type Type = super::Profile; + } + + impl ObjectImpl for Profile { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecBoolean::builder("builtin") + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY) + .build(), + glib::ParamSpecString::builder("name") + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY) + .build(), + glib::ParamSpecString::builder("file-extension") + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY) + .build(), + glib::ParamSpecBoxed::builder( + "muxer-profile", + ElementFactoryProfile::static_type(), + ) + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY) + .build(), + glib::ParamSpecBoxed::builder( + "video-encoder-profile", + ElementFactoryProfile::static_type(), + ) + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY) + .build(), + glib::ParamSpecBoxed::builder( + "audio-encoder-profile", + ElementFactoryProfile::static_type(), + ) + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY) + .build(), + glib::ParamSpecBoolean::builder("available") + .flags(glib::ParamFlags::READABLE) + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "builtin" => { + let is_builtin = value.get().unwrap(); + self.is_builtin.set(is_builtin).unwrap(); + } + "name" => { + let name = value.get().unwrap(); + obj.set_name(name); + } + "file-extension" => { + let file_extension = value.get().unwrap(); + obj.set_file_extension(file_extension); + } + "muxer-profile" => { + let muxer_profile = value.get().unwrap(); + obj.set_muxer_profile(muxer_profile); + } + "video-encoder-profile" => { + let video_encoder_profile = value.get().unwrap(); + obj.set_video_encoder_profile(video_encoder_profile); + } + "audio-encoder-profile" => { + let audio_encoder_profile = value.get().unwrap(); + obj.set_audio_encoder_profile(audio_encoder_profile); + } + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "builtin" => obj.is_builtin().to_value(), + "name" => obj.name().to_value(), + "file-extension" => obj.file_extension().to_value(), + "muxer-profile" => obj.muxer_profile().to_value(), + "video-encoder-profile" => obj.video_encoder_profile().to_value(), + "audio-encoder-profile" => obj.audio_encoder_profile().to_value(), + "available" => obj.is_available().to_value(), + _ => unimplemented!(), + } + } + } +} + +glib::wrapper! { + pub struct Profile(ObjectSubclass); +} + +impl Profile { + pub fn new(name: &str) -> Self { + glib::Object::builder() + .property("name", name) + .build() + .expect("Failed to create Profile.") + } + + pub fn new_builtin(name: &str) -> Self { + glib::Object::builder() + .property("builtin", true) + .property("name", name) + .build() + .expect("Failed to create Profile.") + } + + pub fn new_from(profile: &Self, name: &str) -> Self { + glib::Object::builder() + .property("name", name) + .property("file-extension", profile.file_extension()) + .property("muxer-profile", profile.muxer_profile()) + .property("video-encoder-profile", profile.video_encoder_profile()) + .property("audio-encoder-profile", profile.audio_encoder_profile()) + .build() + .expect("Failed to create Profile.") + } + + pub fn is_builtin(&self) -> bool { + *self.imp().is_builtin.get().unwrap() + } + + pub fn set_name(&self, name: &str) { + if name == self.name() { + return; + } + + self.imp().name.replace(name.to_string()); + self.notify("name"); + } + + pub fn name(&self) -> String { + self.imp().name.borrow().clone() + } + + pub fn set_file_extension(&self, file_extension: &str) { + if file_extension == self.file_extension().as_str() { + return; + } + + self.imp() + .file_extension + .replace(file_extension.to_string()); + self.notify("file-extension"); + } + + pub fn file_extension(&self) -> String { + self.imp().file_extension.borrow().clone() + } + + pub fn set_muxer_profile(&self, profile: Option) { + if profile == self.muxer_profile() { + return; + } + + let imp = self.imp(); + imp.muxer_profile.replace(profile); + self.update_available(); + self.notify("muxer-profile"); + } + + pub fn muxer_profile(&self) -> Option { + self.imp().muxer_profile.borrow().clone() + } + + pub fn set_video_encoder_profile(&self, profile: Option) { + if profile == self.video_encoder_profile() { + return; + } + + let imp = self.imp(); + imp.video_encoder_profile.replace(profile); + self.update_available(); + self.notify("video-encoder-profile"); + } + + pub fn video_encoder_profile(&self) -> Option { + self.imp().video_encoder_profile.borrow().clone() + } + + pub fn set_audio_encoder_profile(&self, profile: Option) { + if profile == self.audio_encoder_profile() { + return; + } + + let imp = self.imp(); + imp.audio_encoder_profile.replace(profile); + self.update_available(); + self.notify("audio-encoder-profile"); + } + + pub fn audio_encoder_profile(&self) -> Option { + self.imp().audio_encoder_profile.borrow().clone() + } + + pub fn is_available(&self) -> bool { + self.imp().is_available.get() + } + + pub fn to_encoding_profile(&self) -> Result { + let muxer_profile = self + .muxer_profile() + .ok_or_else(|| anyhow!("Profile `{}` has no muxer profile", self.name()))?; + let muxer_factory = muxer_profile.factory()?; + + // Video Encoder + let video_encoder_profile = self + .video_encoder_profile() + .ok_or_else(|| anyhow!("Profile `{}` has no video encoder profile", self.name()))?; + let video_profile_format = video_encoder_profile.format()?; + ensure!( + muxer_factory.can_sink_any_caps(video_profile_format), + "`{}` src is incompatible on `{}` sink", + video_encoder_profile.factory_name(), + muxer_profile.factory_name() + ); + let gst_video_profile = gst_pbutils::EncodingVideoProfile::builder(video_profile_format) + .preset_name(video_encoder_profile.factory_name()) + .presence(0) + .build(); + gst_video_profile.set_element_properties(video_encoder_profile.element_properties()); + + // Audio Encoder + let audio_encoder_profile = self + .audio_encoder_profile() + .ok_or_else(|| anyhow!("Profile `{}` has no audio encoder profile", self.name()))?; + let audio_profile_format = audio_encoder_profile.format()?; + ensure!( + muxer_factory.can_sink_any_caps(audio_profile_format), + "`{}` src is incompatible on `{}` sink", + audio_encoder_profile.factory_name(), + muxer_profile.factory_name() + ); + let gst_audio_profile = gst_pbutils::EncodingAudioProfile::builder(audio_profile_format) + .preset_name(audio_encoder_profile.factory_name()) + .presence(0) + .build(); + gst_audio_profile.set_element_properties(audio_encoder_profile.element_properties()); + + // Muxer + let gst_container_profile = + gst_pbutils::EncodingContainerProfile::builder(muxer_profile.format()?) + .add_profile(&gst_video_profile) + .add_profile(&gst_audio_profile) + .presence(0) + .build(); + gst_container_profile.set_element_properties(muxer_profile.element_properties()); + + Ok(gst_container_profile) + } + + fn update_available(&self) { + let is_available = self + .muxer_profile() + .map_or(true, |profile| profile.factory().is_ok()) + && self + .video_encoder_profile() + .map_or(true, |profile| profile.factory().is_ok()) + && self + .audio_encoder_profile() + .map_or(true, |profile| profile.factory().is_ok()); + + if is_available == self.is_available() { + return; + } + + self.imp().is_available.set(is_available); + self.notify("available"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn new_simple_profile( + muxer_factory_name: &str, + video_encoder_factory_name: &str, + audio_encoder_factory_name: &str, + ) -> Profile { + let p = Profile::new(""); + p.set_muxer_profile(Some(ElementFactoryProfile::new(muxer_factory_name))); + p.set_video_encoder_profile(Some(ElementFactoryProfile::new(video_encoder_factory_name))); + p.set_audio_encoder_profile(Some(ElementFactoryProfile::new(audio_encoder_factory_name))); + p + } + + #[test] + fn incompatibles() { + let a = new_simple_profile("webmmux", "x264enc", "opusenc"); + assert_eq!( + a.to_encoding_profile().unwrap_err().to_string(), + "`x264enc` src is incompatible on `webmmux` sink" + ); + + let b = new_simple_profile("webmmux", "vp8enc", "lamemp3enc"); + assert_eq!( + b.to_encoding_profile().unwrap_err().to_string(), + "`lamemp3enc` src is incompatible on `webmmux` sink" + ); + } +} diff --git a/src/profile_manager.rs b/src/profile_manager.rs new file mode 100644 index 000000000..1f8e2b748 --- /dev/null +++ b/src/profile_manager.rs @@ -0,0 +1,346 @@ +use gst::prelude::*; +use gtk::{gio, glib, prelude::*, subclass::prelude::*}; +use once_cell::unsync::OnceCell; + +use std::cell::RefCell; + +use crate::{element_factory_profile::ElementFactoryProfile, profile::Profile, utils}; + +// TODO serialize + +const SUPPORTED_MUXERS: [&str; 3] = ["webmmux", "mp4mux", "matroskamux"]; +const SUPPORTED_VIDEO_ENCODERS: [&str; 2] = ["vp8enc", "x264enc"]; +const SUPPORTED_AUDIO_ENCODERS: [&str; 2] = ["opusenc", "lamemp3enc"]; + +mod imp { + use super::*; + use once_cell::sync::Lazy; + + #[derive(Debug, Default)] + pub struct ProfileManager { + pub(super) active_profile: RefCell>, + + pub(super) last_active_profile: RefCell>, + pub(super) profiles: RefCell>, + + pub(super) known_muxers: OnceCell, + pub(super) known_audio_encoders: OnceCell, + pub(super) known_video_encoders: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ProfileManager { + const NAME: &'static str = "KoohaProfileManager"; + type Type = super::ProfileManager; + type Interfaces = (gio::ListModel,); + } + + impl ObjectImpl for ProfileManager { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::builder("active-profile", Profile::static_type()) + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY) + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "active-profile" => { + let profile = value.get().unwrap(); + obj.set_active_profile(profile); + } + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "active-profile" => obj.active_profile().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + for profile in builtin_profiles() { + obj.add_profile(profile); + } + + if let Some(first_item) = obj.get_profile(0) { + obj.set_active_profile(Some(&first_item)); + } + } + } + + impl ListModelImpl for ProfileManager { + fn item_type(&self, _obj: &Self::Type) -> glib::Type { + Profile::static_type() + } + + fn n_items(&self, _obj: &Self::Type) -> u32 { + self.profiles.borrow().len() as u32 + } + + fn item(&self, obj: &Self::Type, position: u32) -> Option { + obj.get_profile(position).map(|profile| profile.upcast()) + } + } +} + +glib::wrapper! { + pub struct ProfileManager(ObjectSubclass) + @implements gio::ListModel; +} + +impl ProfileManager { + pub fn new() -> Self { + glib::Object::new(&[]).expect("Failed to create ProfileManager.") + } + + pub fn active_profile(&self) -> Option { + self.imp().active_profile.borrow().clone() + } + + pub fn set_active_profile(&self, profile: Option<&Profile>) { + let old_profile = self.active_profile(); + + if profile == old_profile.as_ref() { + return; + } + + let imp = self.imp(); + imp.last_active_profile.replace(old_profile); + + tracing::debug!( + "Set active profile to {:?}", + profile.map(|profile| profile.name()) + ); + + if let Some(profile) = profile { + if !self.contains_profile(profile) { + self.add_profile(profile.clone()); + } + } + + imp.active_profile.replace(profile.cloned()); + self.notify("active-profile"); + } + + pub fn connect_active_profile_notify(&self, f: F) -> glib::SignalHandlerId + where + F: Fn(&Self) + 'static, + { + self.connect_notify_local(Some("active-profile"), move |obj, _| f(obj)) + } + + pub fn add_profile(&self, profile: Profile) { + let position_appended = { + let mut profiles = self.imp().profiles.borrow_mut(); + profiles.push(profile); + profiles.len() as u32 - 1 + }; + self.items_changed(position_appended, 0, 1); + } + + pub fn remove_profile(&self, profile: &Profile) -> bool { + let imp = self.imp(); + let position = imp + .profiles + .borrow() + .iter() + .position(|stored_profile| stored_profile == profile); + + if let Some(position) = position { + let removed = imp.profiles.borrow_mut().remove(position); + self.items_changed(position as u32, 1, 0); + + if Some(&removed) == self.active_profile().as_ref() { + // Clone to prevent BorrowMutError + let last_active_profile = imp.last_active_profile.borrow().as_ref().cloned(); + if let Some(ref last_active_profile) = last_active_profile { + self.set_active_profile(Some(last_active_profile)); + } + } + + if Some(&removed) == imp.last_active_profile.borrow().as_ref() { + imp.last_active_profile.replace(None); + } + } else { + tracing::debug!( + "Didn't delete profile with name `{}` as it does not exist", + profile.name() + ); + } + + position.is_some() + } + + pub fn known_muxers(&self) -> >k::SortListModel { + self.imp().known_muxers.get_or_init(|| { + new_element_factory_sort_list_model( + gst::ElementFactoryType::MUXER, + gst::Rank::Primary, + &SUPPORTED_MUXERS, + ) + }) + } + + pub fn known_video_encoders(&self) -> >k::SortListModel { + self.imp().known_video_encoders.get_or_init(|| { + new_element_factory_sort_list_model( + gst::ElementFactoryType::VIDEO_ENCODER, + gst::Rank::None, + &SUPPORTED_VIDEO_ENCODERS, + ) + }) + } + + pub fn known_audio_encoders(&self) -> >k::SortListModel { + self.imp().known_audio_encoders.get_or_init(|| { + new_element_factory_sort_list_model( + gst::ElementFactoryType::AUDIO_ENCODER, + gst::Rank::None, + &SUPPORTED_AUDIO_ENCODERS, + ) + }) + } + + fn get_profile(&self, position: u32) -> Option { + self.imp().profiles.borrow().get(position as usize).cloned() + } + + fn contains_profile(&self, profile: &Profile) -> bool { + self.imp().profiles.borrow().contains(profile) + } +} + +impl Default for ProfileManager { + fn default() -> Self { + Self::new() + } +} + +fn builtin_profiles() -> Vec { + // TODO make builtins readonly + vec![ + // TODO bring back gif support `gifenc repeat=-1 speed=30`. Disable `win.record-speaker` and `win.record-mic` actions. 15 fps override + // TODO vaapi? + // TODO Handle missing plugins add warning if missing + { + let p = Profile::new_builtin("WebM"); + p.set_file_extension("webm"); + p.set_muxer_profile(Some(ElementFactoryProfile::new("webmmux"))); + p.set_video_encoder_profile(Some( + ElementFactoryProfile::builder("vp8enc") + .property("max-quantizer", 17) + .property("cpu-used", 16) + .property("cq-level", 13) + .property("deadline", 1) + .property("static-threshold", 100) + .property_from_str("keyframe-mode", "disabled") + .property("buffer-size", 20000) + .property("threads", utils::ideal_thread_count()) + .build(), + )); + p.set_audio_encoder_profile(Some(ElementFactoryProfile::new("opusenc"))); + p + }, + { + let p = Profile::new_builtin("MP4"); + p.set_file_extension("mp4"); + p.set_muxer_profile(Some(ElementFactoryProfile::new("mp4mux"))); + p.set_video_encoder_profile(Some( + ElementFactoryProfile::builder("x264enc") + .format_field("profile", "baseline") + .property("qp-max", 17) + .property_from_str("speed-preset", "superfast") + .property("threads", utils::ideal_thread_count()) + .build(), + )); + p.set_audio_encoder_profile(Some(ElementFactoryProfile::new("lamemp3enc"))); + p + }, + { + let p = Profile::new_builtin("Matroska"); + p.set_file_extension("mkv"); + p.set_muxer_profile(Some(ElementFactoryProfile::new("matroskamux"))); + p.set_video_encoder_profile(Some( + ElementFactoryProfile::builder("x264enc") + .format_field("profile", "baseline") + .property("qp-max", 17) + .property_from_str("speed-preset", "superfast") + .property("threads", utils::ideal_thread_count()) + .build(), + )); + p.set_audio_encoder_profile(Some(ElementFactoryProfile::new("opusenc"))); + p + }, + ] +} + +fn new_element_factory_sort_list_model( + type_: gst::ElementFactoryType, + min_rank: gst::Rank, + sort_first_names: &'static [&str], +) -> gtk::SortListModel { + fn new_sorter>( + func: impl Fn(&T, &T) -> gtk::Ordering + 'static, + ) -> gtk::Sorter { + gtk::CustomSorter::new(move |a, b| { + let ef_a = a.downcast_ref().unwrap(); + let ef_b = b.downcast_ref().unwrap(); + func(ef_a, ef_b) + }) + .upcast() + } + + let factories = gst::ElementFactory::factories_with_type(type_, min_rank); + + let sorter = gtk::MultiSorter::new(); + sorter.append(&new_sorter( + |a: &gst::ElementFactory, b: &gst::ElementFactory| a.rank().cmp(&b.rank()).reverse().into(), + )); + sorter.append(&new_sorter( + move |a: &gst::ElementFactory, b: &gst::ElementFactory| { + let a_score = sort_first_names + .iter() + .position(|name| *name == a.name()) + .map_or(i32::MAX, |index| index as i32); + let b_score = sort_first_names + .iter() + .position(|name| *name == b.name()) + .map_or(i32::MAX, |index| index as i32); + a_score.cmp(&b_score).into() + }, + )); + + let list_store = gio::ListStore::new(gst::ElementFactory::static_type()); + list_store.splice(0, 0, &factories.collect::>()); + gtk::SortListModel::new(Some(&list_store), Some(&sorter)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_profiles_work() { + for profile in builtin_profiles() { + assert!(profile.to_encoding_profile().is_ok()); + assert!(!profile.file_extension().is_empty()); + assert!(profile.is_builtin()); + } + } +} diff --git a/src/profile_tile.rs b/src/profile_tile.rs new file mode 100644 index 000000000..886a90f94 --- /dev/null +++ b/src/profile_tile.rs @@ -0,0 +1,239 @@ +use gettextrs::gettext; +use gtk::{ + glib::{self, closure_local}, + prelude::*, + subclass::prelude::*, +}; + +use std::cell::{Cell, RefCell}; + +use crate::{element_factory_profile::ElementFactoryProfile, profile::Profile}; + +mod imp { + use super::*; + use glib::subclass::Signal; + use gtk::CompositeTemplate; + use once_cell::sync::Lazy; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/io/github/seadve/Kooha/ui/profile-tile.ui")] + pub struct ProfileTile { + #[template_child] + pub(super) name_label: TemplateChild, + #[template_child] + pub(super) muxer_label: TemplateChild, + #[template_child] + pub(super) video_encoder_label: TemplateChild, + #[template_child] + pub(super) audio_encoder_label: TemplateChild, + #[template_child] + pub(super) builtin_attribute: TemplateChild, + #[template_child] + pub(super) unavailable_attribute: TemplateChild, + + pub(super) profile: RefCell>, + pub(super) is_selected: Cell, + + pub(super) binding_group: glib::BindingGroup, + } + + #[glib::object_subclass] + impl ObjectSubclass for ProfileTile { + const NAME: &'static str = "KoohaProfileTile"; + type Type = super::ProfileTile; + type ParentType = gtk::FlowBoxChild; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + + klass.set_css_name("profiletile"); + + klass.install_action("profile-tile.delete", None, |obj, _, _| { + obj.emit_by_name::<()>("delete-request", &[]); + }); + + klass.install_action("profile-tile.copy", None, |obj, _, _| { + obj.emit_by_name::<()>("copy-request", &[]); + }); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ProfileTile { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + // Profile to show by self + glib::ParamSpecObject::builder("profile", Profile::static_type()) + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY) + .build(), + // Whether self should be displayed as selected + glib::ParamSpecBoolean::builder("selected") + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY) + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "profile" => { + let profile = value.get().unwrap(); + obj.set_profile(profile); + } + "selected" => { + let is_selected = value.get().unwrap(); + obj.set_selected(is_selected); + } + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "profile" => obj.profile().to_value(), + "selected" => obj.is_selected().to_value(), + _ => unimplemented!(), + } + } + + fn signals() -> &'static [glib::subclass::Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![ + Signal::builder("delete-request", &[], <()>::static_type().into()).build(), + Signal::builder("copy-request", &[], <()>::static_type().into()).build(), + ] + }); + + SIGNALS.as_ref() + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + let profile_to_label_func = |_: &glib::Binding, value: &glib::Value| { + let profile = value.get::>().unwrap(); + Some(profile.map_or_else( + || format!("{}", gettext("None")).to_value(), + |profile| profile.factory_name().to_value(), + )) + }; + self.binding_group + .bind("name", &self.name_label.get(), "label") + .build(); + self.binding_group + .bind("muxer-profile", &self.muxer_label.get(), "label") + .transform_to(profile_to_label_func) + .build(); + self.binding_group + .bind( + "video-encoder-profile", + &self.video_encoder_label.get(), + "label", + ) + .transform_to(profile_to_label_func) + .build(); + self.binding_group + .bind( + "audio-encoder-profile", + &self.audio_encoder_label.get(), + "label", + ) + .transform_to(profile_to_label_func) + .build(); + self.binding_group + .bind("builtin", &self.builtin_attribute.get(), "visible") + .build(); + self.binding_group + .bind("available", &self.unavailable_attribute.get(), "visible") + .flags(glib::BindingFlags::INVERT_BOOLEAN) + .build(); + } + } + + impl WidgetImpl for ProfileTile {} + impl FlowBoxChildImpl for ProfileTile {} +} + +glib::wrapper! { + pub struct ProfileTile(ObjectSubclass) + @extends gtk::Widget, gtk::FlowBoxChild; +} + +impl ProfileTile { + pub fn new(profile: &Profile) -> Self { + glib::Object::new(&[("profile", profile)]).expect("Failed to create ProfileTile.") + } + + pub fn set_profile(&self, profile: Option<&Profile>) { + if profile == self.profile().as_ref() { + return; + } + + let imp = self.imp(); + imp.profile.replace(profile.cloned()); + imp.binding_group.set_source(profile); + self.notify("profile"); + } + + pub fn profile(&self) -> Option { + self.imp().profile.borrow().clone() + } + + pub fn set_selected(&self, is_selected: bool) { + if is_selected == self.is_selected() { + return; + } + + self.imp().is_selected.set(is_selected); + + if is_selected { + self.add_css_class("selected"); + } else { + self.remove_css_class("selected"); + } + + self.notify("selected"); + } + + pub fn is_selected(&self) -> bool { + self.imp().is_selected.get() + } + + pub fn connect_delete_request(&self, f: F) -> glib::SignalHandlerId + where + F: Fn(&Self) + 'static, + { + self.connect_closure( + "delete-request", + true, + closure_local!(|obj: &Self| { + f(obj); + }), + ) + } + + pub fn connect_copy_request(&self, f: F) -> glib::SignalHandlerId + where + F: Fn(&Self) + 'static, + { + self.connect_closure( + "copy-request", + true, + closure_local!(|obj: &Self| { + f(obj); + }), + ) + } +} diff --git a/src/profile_window.rs b/src/profile_window.rs new file mode 100644 index 000000000..313fa13ae --- /dev/null +++ b/src/profile_window.rs @@ -0,0 +1,470 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::{gettext, ngettext}; +use gst::prelude::*; +use gtk::glib::{self, clone, closure}; +use once_cell::unsync::OnceCell; + +use std::cell::RefCell; + +use crate::{ + element_factory_profile::ElementFactoryProfile, profile::Profile, + profile_manager::ProfileManager, profile_tile::ProfileTile, +}; + +mod imp { + use super::*; + use gtk::CompositeTemplate; + use once_cell::sync::Lazy; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/io/github/seadve/Kooha/ui/profile-window.ui")] + pub struct ProfileWindow { + #[template_child] + pub(super) toast_overlay: TemplateChild, + #[template_child] + pub(super) profiles_box: TemplateChild, + #[template_child] + pub(super) name_row: TemplateChild, + #[template_child] + pub(super) file_extension_row: TemplateChild, + #[template_child] + pub(super) muxer_row: TemplateChild, + #[template_child] + pub(super) video_encoder_row: TemplateChild, + #[template_child] + pub(super) audio_encoder_row: TemplateChild, + + pub(super) model: RefCell>, + + pub(super) profile_purgatory: RefCell>, + pub(super) undo_delete_toast: RefCell>, + + pub(super) encoder_filter: OnceCell, + pub(super) model_signal_handler_ids: RefCell>, + + pub(super) name_row_binding: RefCell>, + pub(super) file_extension_row_binding: RefCell>, + pub(super) muxer_row_handler_id: OnceCell, + pub(super) video_encoder_row_handler_id: OnceCell, + pub(super) audio_encoder_row_handler_id: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ProfileWindow { + const NAME: &'static str = "KoohaProfileWindow"; + type Type = super::ProfileWindow; + type ParentType = adw::Window; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + + klass.install_action("profile-window.new-profile", None, |obj, _, _| { + if let Some(model) = obj.model() { + model.set_active_profile(Some(&Profile::new("New Profile"))); + } else { + tracing::warn!("Found no model!"); + } + }); + + klass.install_action("undo-delete-toast.undo", None, |obj, _, _| { + if let Some(model) = obj.model() { + for profile in obj.imp().profile_purgatory.take() { + model.add_profile(profile); + } + } else { + tracing::warn!("Found no model!"); + } + }); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ProfileWindow { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + // Profile model + glib::ParamSpecObject::builder("model", ProfileManager::static_type()) + .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY) + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "model" => { + let model = value.get().unwrap(); + obj.set_model(model); + } + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "model" => obj.model().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + self.profiles_box + .connect_child_activated(clone!(@weak obj => move |_, profile_tile| { + let profile_tile = profile_tile.downcast_ref::().unwrap(); + let profile = profile_tile.profile(); + + obj.model().unwrap().set_active_profile(profile.as_ref()); + })); + + let element_factory_name_expression = + gtk::ClosureExpression::new::( + &[], + closure!(|element_factory: &gst::ElementFactory| { + element_factory + .metadata(&gst::ELEMENT_METADATA_LONGNAME) + .map_or_else(|| element_factory.name(), glib::GString::from) + }), + ); + self.muxer_row + .set_expression(Some(&element_factory_name_expression)); + self.video_encoder_row + .set_expression(Some(&element_factory_name_expression)); + self.audio_encoder_row + .set_expression(Some(&element_factory_name_expression)); + + self.muxer_row + .connect_selected_notify(clone!(@weak obj => move |_| { + obj.encoder_filter().changed(gtk::FilterChange::Different); + })); + + self.muxer_row_handler_id.set(self.muxer_row + .connect_selected_notify(clone!(@weak obj => move |row| { + if let Some(selected_item) = row.selected_item() { + if let Some(profile) = obj.model().and_then(|model| model.active_profile()) { + let element_factory = selected_item.downcast::().unwrap(); + let element_factory_name = element_factory.name(); + profile.set_muxer_profile(Some(ElementFactoryProfile::new(&element_factory_name))); + } else { + tracing::warn!("No model or active profile found but selected an element"); + } + } + }))).unwrap(); + self.video_encoder_row_handler_id.set(self.video_encoder_row + .connect_selected_notify(clone!(@weak obj => move |row| { + if let Some(selected_item) = row.selected_item() { + if let Some(profile) = obj.model().and_then(|model| model.active_profile()) { + let element_factory = selected_item.downcast::().unwrap(); + let element_factory_name = element_factory.name(); + profile.set_video_encoder_profile(Some(ElementFactoryProfile::new(&element_factory_name))); + } else { + tracing::warn!("No model or active profile found but selected an element"); + } + } + }))).unwrap(); + self.audio_encoder_row_handler_id.set(self.audio_encoder_row + .connect_selected_notify(clone!(@weak obj => move |row| { + if let Some(selected_item) = row.selected_item() { + if let Some(profile) = obj.model().and_then(|model| model.active_profile()) { + let element_factory = selected_item.downcast::().unwrap(); + let element_factory_name = element_factory.name(); + profile.set_audio_encoder_profile(Some(ElementFactoryProfile::new(&element_factory_name))); + } else { + tracing::warn!("No model or active profile found but selected an element"); + } + } + }))).unwrap(); + } + + fn dispose(&self, obj: &Self::Type) { + obj.disconnect_model_signals(); + } + } + + impl WidgetImpl for ProfileWindow {} + impl WindowImpl for ProfileWindow {} + impl AdwWindowImpl for ProfileWindow {} +} + +glib::wrapper! { + pub struct ProfileWindow(ObjectSubclass) + @extends gtk::Widget, gtk::Window, adw::Window; +} + +impl ProfileWindow { + pub fn new(model: &ProfileManager) -> Self { + glib::Object::new(&[("model", model)]).expect("Failed to create ProfileWindow.") + } + + pub fn set_model(&self, model: Option<&ProfileManager>) { + if model == self.model().as_ref() { + return; + } + + self.disconnect_model_signals(); + + let imp = self.imp(); + imp.model.replace(model.cloned()); + + imp.profiles_box.bind_model( + model, + clone!(@weak self as obj => @default-panic, move |profile| { + obj.create_profile_tile(profile.downcast_ref::().unwrap()).upcast() + }), + ); + + imp.muxer_row + .block_signal(imp.muxer_row_handler_id.get().unwrap()); + imp.video_encoder_row + .block_signal(imp.video_encoder_row_handler_id.get().unwrap()); + imp.audio_encoder_row + .block_signal(imp.audio_encoder_row_handler_id.get().unwrap()); + + imp.muxer_row + .set_model(model.map(|model| model.known_muxers())); + imp.video_encoder_row.set_model( + model + .map(|model| { + gtk::FilterListModel::new( + Some(model.known_video_encoders()), + Some(self.encoder_filter()), + ) + }) + .as_ref(), + ); + imp.audio_encoder_row.set_model( + model + .map(|model| { + gtk::FilterListModel::new( + Some(model.known_audio_encoders()), + Some(self.encoder_filter()), + ) + }) + .as_ref(), + ); + + imp.muxer_row + .unblock_signal(imp.muxer_row_handler_id.get().unwrap()); + imp.video_encoder_row + .unblock_signal(imp.video_encoder_row_handler_id.get().unwrap()); + imp.audio_encoder_row + .unblock_signal(imp.audio_encoder_row_handler_id.get().unwrap()); + + if let Some(model) = model { + self.add_model_signal(model.connect_active_profile_notify( + clone!(@weak self as obj => move |_| { + obj.update_rows(); + }), + )); + } + + self.update_rows(); + + self.notify("model"); + } + + pub fn model(&self) -> Option { + self.imp().model.borrow().clone() + } + + fn encoder_filter(&self) -> >k::BoolFilter { + let imp = self.imp(); + imp.encoder_filter.get_or_init(|| { + let closure = closure!( + |encoder: &gst::ElementFactory, selected_muxer: Option<&glib::Object>| { + selected_muxer.map_or(false, |muxer| { + let muxer = muxer.downcast_ref::().unwrap(); + encoder.static_pad_templates().any(|template| { + template.direction() == gst::PadDirection::Src + && muxer.can_sink_any_caps(&template.caps()) + }) + }) + } + ); + + gtk::BoolFilter::new(Some(>k::ClosureExpression::new::( + &[imp.muxer_row.property_expression("selected-item")], + closure, + ))) + }) + } + + fn show_undo_delete_toast(&self) { + let imp = self.imp(); + + if imp.undo_delete_toast.borrow().is_none() { + let toast = adw::Toast::builder() + .priority(adw::ToastPriority::High) + .button_label(&gettext("_Undo")) + .action_name("undo-delete-toast.undo") + .build(); + + toast.connect_dismissed(clone!(@weak self as obj => move |_| { + let imp = obj.imp(); + imp.profile_purgatory.borrow_mut().clear(); + imp.undo_delete_toast.take(); + })); + + imp.toast_overlay.add_toast(&toast); + imp.undo_delete_toast.replace(Some(toast)); + } + + // Add this point we should already have a toast setup + if let Some(ref toast) = *imp.undo_delete_toast.borrow() { + let n_removed = imp.profile_purgatory.borrow().len(); + toast.set_title(&ngettext!( + "Removed {} profile", + "Removed {} profiles", + n_removed as u32, + n_removed + )); + } + } + + fn create_profile_tile(&self, profile: &Profile) -> ProfileTile { + let profile_tile = ProfileTile::new(profile); + + let model = self.model().unwrap(); + + if model.active_profile().as_ref() == Some(profile) { + profile_tile.set_selected(true); + } + + self.add_model_signal(model.connect_active_profile_notify( + clone!(@weak profile_tile => move |model| { + profile_tile.set_selected(profile_tile.profile() == model.active_profile()); + }), + )); + + profile_tile.connect_delete_request(clone!(@weak self as obj => move |profile_tile| { + let to_remove = profile_tile.profile().unwrap(); + if obj.model().unwrap().remove_profile(&to_remove) { + obj.imp().profile_purgatory.borrow_mut().push(to_remove); + obj.show_undo_delete_toast(); + } + })); + + profile_tile.connect_copy_request(clone!(@weak self as obj => move |profile_tile| { + let original = profile_tile.profile().unwrap(); + let copy = Profile::new_from(&original, &gettext!("{} (copy)", original.name())); + obj.model().unwrap().set_active_profile(Some(©)); + })); + + profile_tile + } + + fn add_model_signal(&self, handler_id: glib::SignalHandlerId) { + self.imp() + .model_signal_handler_ids + .borrow_mut() + .push(handler_id); + } + + fn disconnect_model_signals(&self) { + for handler_id in self.imp().model_signal_handler_ids.borrow_mut().drain(..) { + if let Some(model) = self.model() { + model.disconnect(handler_id); + } else { + tracing::warn!("Model removed before disconnecting signals!"); + } + } + } + + fn update_rows(&self) { + let imp = self.imp(); + + if let Some(binding) = imp.name_row_binding.take() { + binding.unbind(); + } + if let Some(binding) = imp.file_extension_row_binding.take() { + binding.unbind(); + } + + let active_profile = self.model().and_then(|model| model.active_profile()); + let has_active_profile = active_profile.is_some(); + + imp.name_row.set_visible(has_active_profile); + imp.file_extension_row.set_visible(has_active_profile); + imp.muxer_row.set_visible(has_active_profile); + imp.video_encoder_row.set_visible(has_active_profile); + imp.audio_encoder_row.set_visible(has_active_profile); + + let active_profile = if let Some(profile) = active_profile { + profile + } else { + return; + }; + + imp.name_row_binding.replace(Some( + active_profile + .bind_property("name", &imp.name_row.get(), "text") + .flags(glib::BindingFlags::SYNC_CREATE | glib::BindingFlags::BIDIRECTIONAL) + .build(), + )); + imp.file_extension_row_binding.replace(Some( + active_profile + .bind_property("file-extension", &imp.file_extension_row.get(), "text") + .transform_to(|_: &glib::Binding, value: &glib::Value| { + let file_extension = value.get::>().unwrap(); + Some(file_extension.unwrap_or_default().to_value()) + }) + .flags(glib::BindingFlags::SYNC_CREATE | glib::BindingFlags::BIDIRECTIONAL) + .build(), + )); + + imp.muxer_row + .block_signal(imp.muxer_row_handler_id.get().unwrap()); + imp.video_encoder_row + .block_signal(imp.video_encoder_row_handler_id.get().unwrap()); + imp.audio_encoder_row + .block_signal(imp.audio_encoder_row_handler_id.get().unwrap()); + + set_selected_item(&imp.muxer_row.get(), |item: gst::ElementFactory| { + active_profile + .muxer_profile() + .and_then(|p| p.factory().ok().cloned()) + .map_or(false, |factory| factory == item) + }); + set_selected_item(&imp.video_encoder_row.get(), |item: gst::ElementFactory| { + active_profile + .video_encoder_profile() + .and_then(|p| p.factory().ok().cloned()) + .map_or(false, |factory| factory == item) + }); + set_selected_item(&imp.audio_encoder_row.get(), |item: gst::ElementFactory| { + active_profile + .audio_encoder_profile() + .and_then(|p| p.factory().ok().cloned()) + .map_or(false, |factory| factory == item) + }); + + imp.muxer_row + .unblock_signal(imp.muxer_row_handler_id.get().unwrap()); + imp.video_encoder_row + .unblock_signal(imp.video_encoder_row_handler_id.get().unwrap()); + imp.audio_encoder_row + .unblock_signal(imp.audio_encoder_row_handler_id.get().unwrap()); + } +} + +fn set_selected_item>(combo: &adw::ComboRow, func: impl Fn(I) -> bool) { + let model = combo.model().unwrap(); + combo.set_selected( + (0..model.n_items()) + .find(|&i| func(model.item(i).unwrap().downcast().unwrap())) + .unwrap_or(gtk::INVALID_LIST_POSITION), + ); +} diff --git a/src/recording.rs b/src/recording.rs index d821c752f..d79a628d5 100644 --- a/src/recording.rs +++ b/src/recording.rs @@ -1,4 +1,4 @@ -use anyhow::{ensure, Context, Error, Result}; +use anyhow::{anyhow, ensure, Context, Error, Result}; use gettextrs::gettext; use gst::prelude::*; use gtk::{ @@ -10,7 +10,6 @@ use once_cell::{sync::Lazy, unsync::OnceCell}; use std::{ cell::{Cell, RefCell}, os::unix::prelude::RawFd, - path::{Path, PathBuf}, rc::Rc, time::Duration, }; @@ -20,9 +19,10 @@ use crate::{ audio_device::{self, Class as AudioDeviceClass}, cancelled::Cancelled, help::{ErrorExt, ResultExt}, - pipeline_builder::PipelineBuilder, + pipeline::PipelineBuilder, + profile::Profile, screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType, Stream}, - settings::{CaptureMode, Settings, VideoFormat}, + settings::{CaptureMode, Settings}, timer::Timer, utils, }; @@ -139,13 +139,18 @@ impl Recording { glib::Object::new(&[]).expect("Failed to create Recording.") } - pub async fn start(&self, parent: Option<&impl IsA>, settings: Settings) { + pub async fn start( + &self, + parent: Option<&impl IsA>, + settings: &Settings, + profile: &Profile, + ) { if !matches!(self.state(), State::Init) { tracing::error!("Trying to start recording on a non-init state"); return; } - if let Err(err) = self.start_inner(parent, settings).await { + if let Err(err) = self.start_inner(parent, settings, profile).await { self.close_session(); self.set_finished(Err(err)); } @@ -154,7 +159,8 @@ impl Recording { async fn start_inner( &self, parent: Option<&impl IsA>, - settings: Settings, + settings: &Settings, + profile: &Profile, ) -> Result<()> { let imp = self.imp(); @@ -187,24 +193,18 @@ impl Recording { settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); // setup path - let video_format = settings.video_format(); - let recording_path = new_recording_path(&settings.saving_location(), video_format); let mut pipeline_builder = PipelineBuilder::new( - &recording_path, + &settings.saving_location(), settings.video_framerate(), - video_format, + profile.clone(), fd, streams, ); - imp.file.set(gio::File::for_path(&recording_path)).unwrap(); // select area if settings.capture_mode() == CaptureMode::Selection { - let (selection, screen) = AreaSelector::select_area().await?; - - pipeline_builder - .coordinates(selection) - .actual_screen(screen); + let (coords, screen) = AreaSelector::select_area().await?; + pipeline_builder.select_area_context(coords, screen); } // setup timer @@ -376,6 +376,20 @@ impl Recording { ) } + fn file(&self) -> Result<&gio::File> { + let imp = self.imp(); + self.imp().file.get_or_try_init(|| { + let location = imp + .pipeline + .get() + .ok_or_else(|| anyhow!("Pipeline not set"))? + .by_name("filesink") + .ok_or_else(|| anyhow!("Element filesink not found on pipeline"))? + .property::("location"); + Ok(gio::File::for_path(location)) + }) + } + fn set_state(&self, state: State) { if state == self.state() { return; @@ -412,12 +426,14 @@ impl Recording { /// Deletes recording file on background fn delete_file(&self) { - if let Some(file) = self.imp().file.get() { + if let Ok(file) = self.file() { file.delete_async(glib::PRIORITY_DEFAULT_IDLE, gio::Cancellable::NONE, |res| { if let Err(err) = res { tracing::warn!("Failed to delete recording file: {:?}", err); } }); + } else { + tracing::error!("Failed to delete recording file: Failed to get file"); } } @@ -429,6 +445,10 @@ impl Recording { .and_then(|pipeline| pipeline.query_position::()) .unwrap_or(gst::ClockTime::ZERO); + if clock_time == self.duration() { + return; + } + self.imp().duration.set(clock_time); self.notify("duration"); } @@ -436,8 +456,6 @@ impl Recording { fn handle_bus_message(&self, message: &gst::Message) -> glib::Continue { use gst::MessageView; - tracing::trace!("Received bus message {:?}", message); - let imp = self.imp(); match message.view() { @@ -463,9 +481,9 @@ impl Recording { let error = if e.error().matches(gst::ResourceError::OpenWrite) { error.help( gettext("Make sure that the saving location exists and is accessible."), - if let Some(ref path) = imp - .file - .get() + if let Some(ref path) = self + .file() + .ok() .and_then(|f| f.path()) .and_then(|path| path.parent().map(|p| p.to_owned())) { @@ -501,34 +519,32 @@ impl Recording { source_id.remove(); } - let file = imp.file.get().unwrap(); - - debug_assert_eq!( - self.pipeline() - .by_name("filesink") - .map(|fs| fs.property::("location")) - .map(|path| PathBuf::from(&path)), - Some(file.path().unwrap()) - ); - - self.set_finished(Ok(file.clone())); + self.set_finished(Ok(self.file().unwrap().clone())); Continue(false) } MessageView::StateChanged(sc) => { + let new_state = sc.current(); + if message.src().as_ref() != imp .pipeline .get() .map(|pipeline| pipeline.upcast_ref::()) { + tracing::trace!( + "`{}` changed state from `{:?}` -> `{:?}`", + message + .src() + .map_or_else(|| "".into(), |e| e.name()), + sc.old(), + new_state, + ); return Continue(true); } - let new_state = sc.current(); - tracing::debug!( - "Pipeline state changed from `{:?}` -> `{:?}`", + "Pipeline changed state from `{:?}` -> `{:?}`", sc.old(), new_state, ); @@ -542,7 +558,18 @@ impl Recording { Continue(true) } - _ => Continue(true), + MessageView::Warning(w) => { + tracing::warn!("Received warning message on bus: {:?}", w); + Continue(true) + } + MessageView::Info(i) => { + tracing::debug!("Received info message on bus: {:?}", i); + Continue(true) + } + other => { + tracing::trace!("Received other message on bus: {:?}", other); + Continue(true) + } } } } @@ -553,23 +580,6 @@ impl Default for Recording { } } -fn new_recording_path(saving_location: &Path, video_format: VideoFormat) -> PathBuf { - let file_name = glib::DateTime::now_local() - .expect("You are somehow on year 9999") - .format("Kooha-%F-%H-%M-%S") - .expect("Invalid format string"); - - let mut path = saving_location.join(file_name); - path.set_extension(match video_format { - VideoFormat::Webm => "webm", - VideoFormat::Mkv => "mkv", - VideoFormat::Mp4 => "mp4", - VideoFormat::Gif => "gif", - }); - - path -} - async fn new_screencast_session( cursor_mode: CursorMode, source_type: SourceType, @@ -584,15 +594,15 @@ async fn new_screencast_session( tracing::debug!( "ScreenCast portal version: {:?}", - screencast_session.version().await + screencast_session.version() ); tracing::debug!( "Available cursor modes: {:?}", - screencast_session.available_cursor_modes().await + screencast_session.available_cursor_modes() ); tracing::debug!( "Available source types: {:?}", - screencast_session.available_source_types().await + screencast_session.available_source_types() ); let (streams, restore_token, fd) = screencast_session diff --git a/src/screencast_session/mod.rs b/src/screencast_session/mod.rs index 91ceef54d..a2c2a816c 100644 --- a/src/screencast_session/mod.rs +++ b/src/screencast_session/mod.rs @@ -135,17 +135,17 @@ impl ScreencastSession { Ok(()) } - pub async fn version(&self) -> Result { + pub fn version(&self) -> Result { self.property("version") } - pub async fn available_cursor_modes(&self) -> Result { + pub fn available_cursor_modes(&self) -> Result { let value = self.property::("AvailableCursorModes")?; CursorMode::from_bits(value).ok_or_else(|| anyhow!("Invalid cursor mode: {}", value)) } - pub async fn available_source_types(&self) -> Result { + pub fn available_source_types(&self) -> Result { let value = self.property::("AvailableSourceTypes")?; SourceType::from_bits(value).ok_or_else(|| anyhow!("Invalid source type: {}", value)) diff --git a/src/utils.rs b/src/utils.rs index 89e58e52b..9aadc1269 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,8 @@ use gtk::gio::glib; -use std::path::Path; +use std::{cmp, path::Path}; + +const MAX_THREAD_COUNT: u32 = 64; /// Spawns a future in the default [`glib::MainContext`] pub fn spawn + 'static>(fut: F) { @@ -12,3 +14,8 @@ pub fn spawn + 'static>(fut: F) { pub fn is_flatpak() -> bool { Path::new("/.flatpak-info").exists() } + +/// Ideal thread count to use for `GStreamer` processing. +pub fn ideal_thread_count() -> u32 { + cmp::min(glib::num_processors(), MAX_THREAD_COUNT) +} diff --git a/src/window.rs b/src/window.rs index a45b096f3..7d8022fe0 100644 --- a/src/window.rs +++ b/src/window.rs @@ -12,8 +12,9 @@ use crate::{ cancelled::Cancelled, config::PROFILE, help::Help, + profile_window::ProfileWindow, recording::{Recording, State as RecordingState}, - settings::{CaptureMode, VideoFormat}, + settings::CaptureMode, toggle_button::ToggleButton, utils, Application, }; @@ -87,6 +88,13 @@ mod imp { .settings() .set_screencast_restore_token(""); }); + + klass.install_action("win.edit-profiles", None, |obj, _, _| { + let profile_window = ProfileWindow::new(Application::default().profile_manager()); + profile_window.set_modal(true); + profile_window.set_transient_for(Some(obj)); + profile_window.present(); + }); } fn instance_init(obj: &glib::subclass::InitializingObject) { @@ -105,7 +113,6 @@ mod imp { obj.setup_settings(); obj.update_view(); - obj.update_audio_toggles_sensitivity(); obj.update_title_label(); } } @@ -244,8 +251,15 @@ impl Window { ]; *imp.recording.lock().await = Some((recording.clone(), handler_ids)); - let settings = Application::default().settings(); - recording.start(Some(self), settings).await; + let application = Application::default(); + recording + .start( + Some(self), + &application.settings(), + // TODO make record button insensitive when no or invalid profile + &application.profile_manager().active_profile().unwrap(), + ) + .await; } async fn toggle_pause(&self) -> Result<()> { @@ -385,14 +399,6 @@ impl Window { } } - fn update_audio_toggles_sensitivity(&self) { - let settings = Application::default().settings(); - let is_enabled = settings.video_format() != VideoFormat::Gif; - - self.action_set_enabled("win.record-speaker", is_enabled); - self.action_set_enabled("win.record-mic", is_enabled); - } - fn update_forget_video_sources_action(&self) { let settings = Application::default().settings(); let has_restore_token = !settings.screencast_restore_token().is_empty(); @@ -411,16 +417,11 @@ impl Window { obj.update_title_label(); })); - settings.connect_video_format_changed(clone!(@weak self as obj => move |_| { - obj.update_audio_toggles_sensitivity(); - })); - settings.connect_screencast_restore_token_changed(clone!(@weak self as obj => move |_| { obj.update_forget_video_sources_action(); })); self.update_title_label(); - self.update_audio_toggles_sensitivity(); self.update_forget_video_sources_action(); self.add_action(&settings.create_record_speaker_action()); @@ -428,7 +429,6 @@ impl Window { self.add_action(&settings.create_show_pointer_action()); self.add_action(&settings.create_capture_mode_action()); self.add_action(&settings.create_record_delay_action()); - self.add_action(&settings.create_video_format_action()); } }