diff --git a/Cargo.toml b/Cargo.toml index 8dbf9250..72071ee9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "video/flavors", "video/gif", "video/rav1e", + "text/wrap", ] [profile.release] diff --git a/text/wrap/Cargo.toml b/text/wrap/Cargo.toml new file mode 100644 index 00000000..7b25b611 --- /dev/null +++ b/text/wrap/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "gst-plugin-textwrap" +version = "0.6.0" +authors = ["Mathieu Duponchelle "] +license = "LGPL-2.1-or-later" +edition = "2018" +description = "Rust Text Wrap Plugin" +repository = "https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs" + +[dependencies] +glib = { git = "https://github.com/gtk-rs/glib" } +once_cell = "1.0" +textwrap = { version = "0.11", features = ["hyphenation"] } +hyphenation = "0.7.1" + +[dependencies.gst] +git = "https://gitlab.freedesktop.org/gstreamer/gstreamer-rs" +features = ["v1_12"] +package="gstreamer" + +[lib] +name = "gstrstextwrap" +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[build-dependencies] +gst-plugin-version-helper = { path="../../version-helper" } + +[dev-dependencies.gst-check] +git = "https://gitlab.freedesktop.org/gstreamer/gstreamer-rs" +package="gstreamer-check" diff --git a/text/wrap/build.rs b/text/wrap/build.rs new file mode 100644 index 00000000..fe307a43 --- /dev/null +++ b/text/wrap/build.rs @@ -0,0 +1,5 @@ +use gst_plugin_version_helper; + +fn main() { + gst_plugin_version_helper::get_info() +} diff --git a/text/wrap/src/gsttextwrap.rs b/text/wrap/src/gsttextwrap.rs new file mode 100644 index 00000000..7644259d --- /dev/null +++ b/text/wrap/src/gsttextwrap.rs @@ -0,0 +1,423 @@ +// Copyright (C) 2020 Mathieu Duponchelle +// +// This library is free software; you can redistribute it and/or +// modify it under the terms of the GNU Library General Public +// License as published by the Free Software Foundation; either +// version 2 of the License, or (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Library General Public License for more details. +// +// You should have received a copy of the GNU Library General Public +// License along with this library; if not, write to the +// Free Software Foundation, Inc., 51 Franklin Street, Suite 500, +// Boston, MA 02110-1335, USA. + +use glib; +use glib::prelude::*; +use glib::subclass; +use glib::subclass::prelude::*; +use gst; +use gst::prelude::*; +use gst::subclass::prelude::*; + +use std::default::Default; +use std::fs::File; +use std::io; +use std::sync::Mutex; + +use once_cell::sync::Lazy; + +use hyphenation::{Load, Standard}; +use textwrap; + +static CAT: Lazy = Lazy::new(|| { + gst::DebugCategory::new( + "textwrap", + gst::DebugColorFlags::empty(), + Some("Text wrapper element"), + ) +}); + +const DEFAULT_DICTIONARY: Option = None; +const DEFAULT_COLUMNS: u32 = 32; /* CEA 608 max columns */ +const DEFAULT_LINES: u32 = 0; + +static PROPERTIES: [subclass::Property; 3] = [ + subclass::Property("dictionary", |name| { + glib::ParamSpec::string( + name, + "Dictionary", + "Path to a dictionary to load at runtime to perform hyphenation, see \ + for more information", + None, + glib::ParamFlags::READWRITE, + ) + }), + subclass::Property("columns", |name| { + glib::ParamSpec::uint( + name, + "Columns", + "Maximum number of columns for any given line", + 1, + std::u32::MAX, + DEFAULT_COLUMNS, + glib::ParamFlags::READWRITE, + ) + }), + subclass::Property("lines", |name| { + glib::ParamSpec::uint( + name, + "Lines", + "Split input buffer into output buffers with max lines (0=do not split)", + 0, + std::u32::MAX, + DEFAULT_LINES, + glib::ParamFlags::READWRITE, + ) + }), +]; + +#[derive(Debug, Clone)] +struct Settings { + dictionary: Option, + columns: u32, + lines: u32, +} + +impl Default for Settings { + fn default() -> Self { + Self { + dictionary: DEFAULT_DICTIONARY, + columns: DEFAULT_COLUMNS, /* CEA 608 max columns */ + lines: DEFAULT_LINES, + } + } +} + +#[allow(clippy::large_enum_variant)] +enum Wrapper { + H(textwrap::Wrapper<'static, Standard>), + N(textwrap::Wrapper<'static, textwrap::NoHyphenation>), +} + +struct State { + wrapper: Option, +} + +impl Wrapper { + fn fill(&self, s: &str) -> String { + match *self { + Wrapper::H(ref w) => w.fill(s), + Wrapper::N(ref w) => w.fill(s), + } + } +} + +impl Default for State { + fn default() -> Self { + Self { wrapper: None } + } +} + +struct TextWrap { + srcpad: gst::Pad, + sinkpad: gst::Pad, + settings: Mutex, + state: Mutex, +} + +impl TextWrap { + fn set_pad_functions(sinkpad: &gst::Pad) { + sinkpad.set_chain_function(|pad, parent, buffer| { + TextWrap::catch_panic_pad_function( + parent, + || Err(gst::FlowError::Error), + |textwrap, element| textwrap.sink_chain(pad, element, buffer), + ) + }); + } + + fn update_wrapper(&self, element: &gst::Element) { + let settings = self.settings.lock().unwrap(); + let mut state = self.state.lock().unwrap(); + + if state.wrapper.is_some() { + return; + } + + state.wrapper = if let Some(dictionary) = &settings.dictionary { + let dict_file = match File::open(dictionary) { + Err(err) => { + gst_error!(CAT, obj: element, "Failed to open dictionary file: {}", err); + return; + } + Ok(dict_file) => dict_file, + }; + + let mut reader = io::BufReader::new(dict_file); + let standard = match Standard::any_from_reader(&mut reader) { + Err(err) => { + gst_error!( + CAT, + obj: element, + "Failed to load standard from file: {}", + err + ); + return; + } + Ok(standard) => standard, + }; + + Some(Wrapper::H(textwrap::Wrapper::with_splitter( + settings.columns as usize, + standard, + ))) + } else { + Some(Wrapper::N(textwrap::Wrapper::with_splitter( + settings.columns as usize, + textwrap::NoHyphenation, + ))) + }; + } + + fn sink_chain( + &self, + _pad: &gst::Pad, + element: &gst::Element, + buffer: gst::Buffer, + ) -> Result { + self.update_wrapper(element); + + let mut pts: gst::ClockTime = buffer + .get_pts() + .ok_or_else(|| { + gst_error!(CAT, obj: element, "Need timestamped buffers"); + gst::FlowError::Error + })? + .into(); + + let duration: gst::ClockTime = buffer + .get_duration() + .ok_or_else(|| { + gst_error!(CAT, obj: element, "Need buffers with duration"); + gst::FlowError::Error + })? + .into(); + + let data = buffer.map_readable().map_err(|_| { + gst_error!(CAT, obj: element, "Can't map buffer readable"); + + gst::FlowError::Error + })?; + + let data = std::str::from_utf8(&data).map_err(|err| { + gst_error!(CAT, obj: element, "Can't decode utf8: {}", err); + + gst::FlowError::Error + })?; + + let lines = self.settings.lock().unwrap().lines; + + let data = { + let state = self.state.lock().unwrap(); + let wrapper = state + .wrapper + .as_ref() + .expect("We should have a wrapper by now"); + wrapper.fill(data) + }; + + // If the lines property was set, we want to split the result into buffers + // of at most N lines. We compute the duration for each of those based on + // the total number of words, and the number of words in each of the split-up + // buffers. + if lines > 0 { + let mut bufferlist = gst::BufferList::new(); + let duration_per_word: gst::ClockTime = + duration / data.split_whitespace().count() as u64; + + for chunk in data.lines().collect::>().chunks(lines as usize) { + let data = chunk.join("\n"); + let duration: gst::ClockTime = + duration_per_word * data.split_whitespace().count() as u64; + let mut buf = gst::Buffer::from_mut_slice(data.into_bytes()); + + { + let buf = buf.get_mut().unwrap(); + + buf.set_pts(pts); + buf.set_duration(duration); + pts += duration; + } + + bufferlist.get_mut().unwrap().add(buf); + } + + self.srcpad.push_list(bufferlist) + } else { + let mut buf = gst::Buffer::from_mut_slice(data.into_bytes()); + + { + let buf = buf.get_mut().unwrap(); + + buf.set_pts(pts); + buf.set_duration(duration); + } + + self.srcpad.push(buf) + } + } +} + +impl ObjectSubclass for TextWrap { + const NAME: &'static str = "RsTextWrap"; + type ParentType = gst::Element; + type Instance = gst::subclass::ElementInstanceStruct; + type Class = subclass::simple::ClassStruct; + + glib_object_subclass!(); + + fn new_with_class(klass: &subclass::simple::ClassStruct) -> Self { + let templ = klass.get_pad_template("sink").unwrap(); + let sinkpad = gst::Pad::new_from_template(&templ, Some("sink")); + let templ = klass.get_pad_template("src").unwrap(); + let srcpad = gst::Pad::new_from_template(&templ, Some("src")); + + srcpad.use_fixed_caps(); + sinkpad.use_fixed_caps(); + + TextWrap::set_pad_functions(&sinkpad); + + let settings = Mutex::new(Settings::default()); + let state = Mutex::new(State::default()); + + Self { + srcpad, + sinkpad, + settings, + state, + } + } + + fn class_init(klass: &mut subclass::simple::ClassStruct) { + klass.set_metadata( + "Text Wrapper", + "Text/Filter", + "Breaks text into fixed-size lines, with optional hyphenationz", + "Mathieu Duponchelle ", + ); + + let caps = gst::Caps::builder("text/x-raw") + .field("format", &"utf8") + .build(); + let src_pad_template = gst::PadTemplate::new( + "src", + gst::PadDirection::Src, + gst::PadPresence::Always, + &caps, + ) + .unwrap(); + klass.add_pad_template(src_pad_template); + + let sink_pad_template = gst::PadTemplate::new( + "sink", + gst::PadDirection::Sink, + gst::PadPresence::Always, + &caps, + ) + .unwrap(); + klass.add_pad_template(sink_pad_template); + + klass.install_properties(&PROPERTIES); + } +} + +impl ObjectImpl for TextWrap { + glib_object_impl!(); + + fn constructed(&self, obj: &glib::Object) { + self.parent_constructed(obj); + + let element = obj.downcast_ref::().unwrap(); + element.add_pad(&self.sinkpad).unwrap(); + element.add_pad(&self.srcpad).unwrap(); + } + + fn set_property(&self, _obj: &glib::Object, id: usize, value: &glib::Value) { + let prop = &PROPERTIES[id]; + + match *prop { + subclass::Property("dictionary", ..) => { + let mut settings = self.settings.lock().unwrap(); + let mut state = self.state.lock().unwrap(); + settings.dictionary = value.get().expect("type checked upstream"); + state.wrapper = None; + } + subclass::Property("columns", ..) => { + let mut settings = self.settings.lock().unwrap(); + let mut state = self.state.lock().unwrap(); + settings.columns = value.get_some().expect("type checked upstream"); + state.wrapper = None; + } + subclass::Property("lines", ..) => { + let mut settings = self.settings.lock().unwrap(); + settings.lines = value.get_some().expect("type checked upstream"); + } + _ => unimplemented!(), + } + } + + fn get_property(&self, _obj: &glib::Object, id: usize) -> Result { + let prop = &PROPERTIES[id]; + + match *prop { + subclass::Property("dictionary", ..) => { + let settings = self.settings.lock().unwrap(); + Ok(settings.dictionary.to_value()) + } + subclass::Property("columns", ..) => { + let settings = self.settings.lock().unwrap(); + Ok(settings.columns.to_value()) + } + subclass::Property("lines", ..) => { + let settings = self.settings.lock().unwrap(); + Ok(settings.lines.to_value()) + } + _ => unimplemented!(), + } + } +} + +impl ElementImpl for TextWrap { + fn change_state( + &self, + element: &gst::Element, + transition: gst::StateChange, + ) -> Result { + gst_info!(CAT, obj: element, "Changing state {:?}", transition); + + match transition { + gst::StateChange::PausedToReady => { + let mut state = self.state.lock().unwrap(); + *state = State::default(); + } + _ => (), + } + + let success = self.parent_change_state(element, transition)?; + + Ok(success) + } +} + +pub fn register(plugin: &gst::Plugin) -> Result<(), glib::BoolError> { + gst::Element::register( + Some(plugin), + "textwrap", + gst::Rank::None, + TextWrap::get_type(), + ) +} diff --git a/text/wrap/src/lib.rs b/text/wrap/src/lib.rs new file mode 100644 index 00000000..c8d09eb9 --- /dev/null +++ b/text/wrap/src/lib.rs @@ -0,0 +1,43 @@ +// Copyright (C) 2020 Mathieu Duponchelle +// +// This library is free software; you can redistribute it and/or +// modify it under the terms of the GNU Library General Public +// License as published by the Free Software Foundation; either +// version 2 of the License, or (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Library General Public License for more details. +// +// You should have received a copy of the GNU Library General Public +// License along with this library; if not, write to the +// Free Software Foundation, Inc., 51 Franklin Street, Suite 500, +// Boston, MA 02110-1335, USA. + +#![recursion_limit = "128"] + +#[macro_use] +extern crate glib; +#[macro_use] +extern crate gst; +extern crate hyphenation; +extern crate textwrap; + +mod gsttextwrap; + +fn plugin_init(plugin: &gst::Plugin) -> Result<(), glib::BoolError> { + gsttextwrap::register(plugin) +} + +gst_plugin_define!( + rstextwrap, + env!("CARGO_PKG_DESCRIPTION"), + plugin_init, + concat!(env!("CARGO_PKG_VERSION"), "-", env!("COMMIT_ID")), + "LGPL", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_REPOSITORY"), + env!("BUILD_REL_DATE") +); diff --git a/text/wrap/tests/textwrap.rs b/text/wrap/tests/textwrap.rs new file mode 100644 index 00000000..7c194f85 --- /dev/null +++ b/text/wrap/tests/textwrap.rs @@ -0,0 +1,123 @@ +// Copyright (C) 2020 Mathieu Duponchelle +// +// This library is free software; you can redistribute it and/or +// modify it under the terms of the GNU Library General Public +// License as published by the Free Software Foundation; either +// version 2 of the License, or (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Library General Public License for more details. +// +// You should have received a copy of the GNU Library General Public +// License along with this library; if not, write to the +// Free Software Foundation, Inc., 51 Franklin Street, Suite 500, +// Boston, MA 02110-1335, USA. + +use glib::prelude::*; + +fn init() { + use std::sync::Once; + static INIT: Once = Once::new(); + + INIT.call_once(|| { + gst::init().unwrap(); + gstrstextwrap::plugin_register_static().expect("textwrap test"); + }); +} + +#[test] +fn test_columns() { + init(); + + let input = b"Split this text up"; + + let expected_output = "Split\nthis\ntext\nup"; + + let mut h = gst_check::Harness::new("textwrap"); + + { + let wrap = h.get_element().expect("Could not create textwrap"); + wrap.set_property("columns", &5u32).unwrap(); + } + + h.set_src_caps_str("text/x-raw, format=utf8"); + + let buf = { + let mut buf = gst::Buffer::from_mut_slice(Vec::from(&input[..])); + let buf_ref = buf.get_mut().unwrap(); + buf_ref.set_pts(gst::ClockTime::from_seconds(0)); + buf_ref.set_duration(gst::ClockTime::from_seconds(2)); + buf + }; + + assert_eq!(h.push(buf), Ok(gst::FlowSuccess::Ok)); + + let buf = h.pull().expect("Couldn't pull buffer"); + + assert_eq!(buf.get_pts(), 0.into()); + assert_eq!(buf.get_duration(), 2 * gst::SECOND); + + let map = buf.map_readable().expect("Couldn't map buffer readable"); + + assert_eq!( + std::str::from_utf8(map.as_ref()), + std::str::from_utf8(expected_output.as_ref()) + ); +} + +#[test] +fn test_lines() { + init(); + + let input = b"Split this text up"; + + let mut h = gst_check::Harness::new("textwrap"); + + { + let wrap = h.get_element().expect("Could not create textwrap"); + wrap.set_property("columns", &5u32).unwrap(); + wrap.set_property("lines", &2u32).unwrap(); + } + + h.set_src_caps_str("text/x-raw, format=utf8"); + + let buf = { + let mut buf = gst::Buffer::from_mut_slice(Vec::from(&input[..])); + let buf_ref = buf.get_mut().unwrap(); + buf_ref.set_pts(gst::ClockTime::from_seconds(0)); + buf_ref.set_duration(gst::ClockTime::from_seconds(2)); + buf + }; + + assert_eq!(h.push(buf), Ok(gst::FlowSuccess::Ok)); + + let buf = h.pull().expect("Couldn't pull buffer"); + + assert_eq!(buf.get_pts(), 0.into()); + assert_eq!(buf.get_duration(), gst::SECOND); + + let expected_output = "Split\nthis"; + + let map = buf.map_readable().expect("Couldn't map buffer readable"); + + assert_eq!( + std::str::from_utf8(map.as_ref()), + std::str::from_utf8(expected_output.as_ref()) + ); + + let buf = h.pull().expect("Couldn't pull buffer"); + + assert_eq!(buf.get_pts(), gst::SECOND); + assert_eq!(buf.get_duration(), gst::SECOND); + + let expected_output = "text\nup"; + + let map = buf.map_readable().expect("Couldn't map buffer readable"); + + assert_eq!( + std::str::from_utf8(map.as_ref()), + std::str::from_utf8(expected_output.as_ref()) + ); +}