diff --git a/net/hlssink3/Cargo.toml b/net/hlssink3/Cargo.toml index 2843c5ab..3fac467d 100644 --- a/net/hlssink3/Cargo.toml +++ b/net/hlssink3/Cargo.toml @@ -16,12 +16,12 @@ once_cell = "1.7.2" m3u8-rs = "5.0" chrono = "0.4" sprintf = "0.1.3" +gst-pbutils = { workspace = true, features = ["v1_22"] } [dev-dependencies] gst-audio.workspace = true gst-video.workspace = true gst-check.workspace = true -gst-pbutils = { workspace = true, features = ["v1_20"] } m3u8-rs = "5.0" anyhow = "1" diff --git a/net/hlssink3/src/basesink.rs b/net/hlssink3/src/basesink.rs new file mode 100644 index 00000000..343dc3ee --- /dev/null +++ b/net/hlssink3/src/basesink.rs @@ -0,0 +1,557 @@ +// Copyright (C) 2021 Rafael Caricio +// Copyright (C) 2023 Seungha Yang +// +// This Source Code Form is subject to the terms of the Mozilla Public License, v2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at +// . +// +// SPDX-License-Identifier: MPL-2.0 + +use crate::playlist::Playlist; +use chrono::{DateTime, Duration, Utc}; +use gio::prelude::*; +use gst::glib; +use gst::prelude::*; +use gst::subclass::prelude::*; +use m3u8_rs::MediaSegment; +use once_cell::sync::Lazy; +use std::fs; +use std::io::Write; +use std::path; +use std::sync::Mutex; + +const DEFAULT_PLAYLIST_LOCATION: &str = "playlist.m3u8"; +const DEFAULT_MAX_NUM_SEGMENT_FILES: u32 = 10; +const DEFAULT_PLAYLIST_LENGTH: u32 = 5; +const DEFAULT_PROGRAM_DATE_TIME_TAG: bool = false; +const DEFAULT_CLOCK_TRACKING_FOR_PDT: bool = true; +const DEFAULT_ENDLIST: bool = true; + +const SIGNAL_GET_PLAYLIST_STREAM: &str = "get-playlist-stream"; +const SIGNAL_GET_FRAGMENT_STREAM: &str = "get-fragment-stream"; +const SIGNAL_DELETE_FRAGMENT: &str = "delete-fragment"; + +static CAT: Lazy = Lazy::new(|| { + gst::DebugCategory::new( + "hlsbasesink", + gst::DebugColorFlags::empty(), + Some("HLS Base sink"), + ) +}); + +struct Settings { + playlist_location: String, + playlist_root: Option, + playlist_length: u32, + max_num_segment_files: usize, + enable_program_date_time: bool, + pdt_follows_pipeline_clock: bool, + enable_endlist: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + playlist_location: String::from(DEFAULT_PLAYLIST_LOCATION), + playlist_root: None, + playlist_length: DEFAULT_PLAYLIST_LENGTH, + max_num_segment_files: DEFAULT_MAX_NUM_SEGMENT_FILES as usize, + enable_program_date_time: DEFAULT_PROGRAM_DATE_TIME_TAG, + pdt_follows_pipeline_clock: DEFAULT_CLOCK_TRACKING_FOR_PDT, + enable_endlist: DEFAULT_ENDLIST, + } + } +} + +pub struct PlaylistContext { + pdt_base_utc: Option>, + pdt_base_running_time: Option, + playlist: Playlist, + old_segment_locations: Vec, + segment_template: String, + playlist_location: String, + max_num_segment_files: usize, + playlist_length: u32, +} + +#[derive(Default)] +pub struct State { + context: Option, +} + +#[derive(Default)] +pub struct HlsBaseSink { + settings: Mutex, + state: Mutex, +} + +#[glib::object_subclass] +impl ObjectSubclass for HlsBaseSink { + const NAME: &'static str = "GstHlsBaseSink"; + type Type = super::HlsBaseSink; + type ParentType = gst::Bin; +} + +pub trait HlsBaseSinkImpl: BinImpl {} + +unsafe impl IsSubclassable for super::HlsBaseSink {} + +impl ObjectImpl for HlsBaseSink { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + obj.set_suppressed_flags(gst::ElementFlags::SINK | gst::ElementFlags::SOURCE); + obj.set_element_flags(gst::ElementFlags::SINK); + } + + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecString::builder("playlist-location") + .nick("Playlist Location") + .blurb("Location of the playlist to write.") + .default_value(Some(DEFAULT_PLAYLIST_LOCATION)) + .build(), + glib::ParamSpecString::builder("playlist-root") + .nick("Playlist Root") + .blurb("Base path for the segments in the playlist file.") + .build(), + glib::ParamSpecUInt::builder("max-files") + .nick("Max files") + .blurb("Maximum number of files to keep on disk. Once the maximum is reached, old files start to be deleted to make room for new ones.") + .build(), + glib::ParamSpecUInt::builder("playlist-length") + .nick("Playlist length") + .blurb("Length of HLS playlist. To allow players to conform to section 6.3.3 of the HLS specification, this should be at least 3. If set to 0, the playlist will be infinite.") + .default_value(DEFAULT_PLAYLIST_LENGTH) + .build(), + glib::ParamSpecBoolean::builder("enable-program-date-time") + .nick("add EXT-X-PROGRAM-DATE-TIME tag") + .blurb("put EXT-X-PROGRAM-DATE-TIME tag in the playlist") + .default_value(DEFAULT_PROGRAM_DATE_TIME_TAG) + .build(), + glib::ParamSpecBoolean::builder("pdt-follows-pipeline-clock") + .nick("Whether Program-Date-Time should follow the pipeline clock") + .blurb("As there might be drift between the wallclock and pipeline clock, this controls whether the Program-Date-Time markers should follow the pipeline clock rate (true), or be skewed to match the wallclock rate (false).") + .default_value(DEFAULT_CLOCK_TRACKING_FOR_PDT) + .build(), + glib::ParamSpecBoolean::builder("enable-endlist") + .nick("Enable Endlist") + .blurb("Write \"EXT-X-ENDLIST\" tag to manifest at the end of stream") + .default_value(DEFAULT_ENDLIST) + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let mut settings = self.settings.lock().unwrap(); + match pspec.name() { + "playlist-location" => { + settings.playlist_location = value + .get::>() + .expect("type checked upstream") + .unwrap_or_else(|| String::from(DEFAULT_PLAYLIST_LOCATION)); + } + "playlist-root" => { + settings.playlist_root = value + .get::>() + .expect("type checked upstream"); + } + "max-files" => { + let max_files: u32 = value.get().expect("type checked upstream"); + settings.max_num_segment_files = max_files as usize; + } + "playlist-length" => { + settings.playlist_length = value.get().expect("type checked upstream"); + } + "enable-program-date-time" => { + settings.enable_program_date_time = value.get().expect("type checked upstream"); + } + "pdt-follows-pipeline-clock" => { + settings.pdt_follows_pipeline_clock = value.get().expect("type checked upstream"); + } + "enable-endlist" => { + settings.enable_endlist = value.get().expect("type checked upstream"); + } + _ => unimplemented!(), + }; + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let settings = self.settings.lock().unwrap(); + match pspec.name() { + "playlist-location" => settings.playlist_location.to_value(), + "playlist-root" => settings.playlist_root.to_value(), + "max-files" => { + let max_files = settings.max_num_segment_files as u32; + max_files.to_value() + } + "playlist-length" => settings.playlist_length.to_value(), + "enable-program-date-time" => settings.enable_program_date_time.to_value(), + "pdt-follows-pipeline-clock" => settings.pdt_follows_pipeline_clock.to_value(), + "enable-endlist" => settings.enable_endlist.to_value(), + _ => unimplemented!(), + } + } + + fn signals() -> &'static [glib::subclass::Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![ + glib::subclass::Signal::builder(SIGNAL_GET_PLAYLIST_STREAM) + .param_types([String::static_type()]) + .return_type::>() + .class_handler(|_, args| { + let elem = args[0].get::().expect("signal arg"); + let playlist_location = args[1].get::().expect("signal arg"); + let imp = elem.imp(); + + Some(imp.new_file_stream(&playlist_location).ok().to_value()) + }) + .accumulator(|_hint, ret, value| { + // First signal handler wins + *ret = value.clone(); + false + }) + .build(), + glib::subclass::Signal::builder(SIGNAL_GET_FRAGMENT_STREAM) + .param_types([String::static_type()]) + .return_type::>() + .class_handler(|_, args| { + let elem = args[0].get::().expect("signal arg"); + let fragment_location = args[1].get::().expect("signal arg"); + let imp = elem.imp(); + + Some(imp.new_file_stream(&fragment_location).ok().to_value()) + }) + .accumulator(|_hint, ret, value| { + // First signal handler wins + *ret = value.clone(); + false + }) + .build(), + glib::subclass::Signal::builder(SIGNAL_DELETE_FRAGMENT) + .param_types([String::static_type()]) + .return_type::() + .class_handler(|_, args| { + let elem = args[0].get::().expect("signal arg"); + let fragment_location = args[1].get::().expect("signal arg"); + let imp = elem.imp(); + + imp.delete_fragment(&fragment_location); + Some(true.to_value()) + }) + .accumulator(|_hint, ret, value| { + // First signal handler wins + *ret = value.clone(); + false + }) + .build(), + ] + }); + + SIGNALS.as_ref() + } +} + +impl GstObjectImpl for HlsBaseSink {} + +impl ElementImpl for HlsBaseSink { + fn change_state( + &self, + transition: gst::StateChange, + ) -> Result { + let ret = self.parent_change_state(transition)?; + + match transition { + gst::StateChange::PlayingToPaused => { + let mut state = self.state.lock().unwrap(); + if let Some(context) = state.context.as_mut() { + // reset mapping from rt to utc. during pause + // rt is stopped but utc keep moving so need to + // calculate the mapping again + context.pdt_base_running_time = None; + context.pdt_base_utc = None + } + } + gst::StateChange::PausedToReady => { + self.close_playlist(); + } + _ => (), + } + + Ok(ret) + } +} + +impl BinImpl for HlsBaseSink {} + +impl HlsBaseSinkImpl for HlsBaseSink {} + +impl HlsBaseSink { + pub fn open_playlist(&self, playlist: Playlist, segment_template: String) { + let mut state = self.state.lock().unwrap(); + let settings = self.settings.lock().unwrap(); + state.context = Some(PlaylistContext { + pdt_base_utc: None, + pdt_base_running_time: None, + playlist, + old_segment_locations: Vec::new(), + segment_template, + playlist_location: settings.playlist_location.clone(), + max_num_segment_files: settings.max_num_segment_files, + playlist_length: settings.playlist_length, + }); + } + + fn close_playlist(&self) { + let mut state = self.state.lock().unwrap(); + if let Some(mut context) = state.context.take() { + if context.playlist.is_rendering() { + context + .playlist + .stop(self.settings.lock().unwrap().enable_endlist); + let _ = self.write_playlist(&mut context); + } + } + } + + pub fn get_fragment_stream(&self, fragment_id: u32) -> Option<(gio::OutputStream, String)> { + let mut state = self.state.lock().unwrap(); + let context = match state.context.as_mut() { + Some(context) => context, + None => { + gst::error!( + CAT, + imp: self, + "Playlist is not configured", + ); + + return None; + } + }; + + let location = match sprintf::sprintf!(&context.segment_template, fragment_id) { + Ok(file_name) => file_name, + Err(err) => { + gst::error!( + CAT, + imp: self, + "Couldn't build file name, err: {:?}", err, + ); + + return None; + } + }; + + gst::trace!( + CAT, + imp: self, + "Segment location formatted: {}", + location + ); + + let stream = match self + .obj() + .emit_by_name::>(SIGNAL_GET_FRAGMENT_STREAM, &[&location]) + { + Some(stream) => stream, + None => return None, + }; + + Some((stream, location)) + } + + pub fn get_segment_uri(&self, location: &str) -> String { + let settings = self.settings.lock().unwrap(); + let file_name = path::Path::new(&location) + .file_name() + .unwrap() + .to_str() + .unwrap(); + + if let Some(playlist_root) = &settings.playlist_root { + format!("{playlist_root}/{file_name}") + } else { + file_name.to_string() + } + } + + pub fn add_segment( + &self, + location: &str, + running_time: Option, + mut segment: MediaSegment, + ) -> Result { + let mut state = self.state.lock().unwrap(); + let context = match state.context.as_mut() { + Some(context) => context, + None => { + gst::error!( + CAT, + imp: self, + "Playlist is not configured", + ); + + return Err(gst::FlowError::Error); + } + }; + + if let Some(running_time) = running_time { + if context.pdt_base_running_time.is_none() { + context.pdt_base_running_time = Some(running_time); + } + + let settings = self.settings.lock().unwrap(); + + // Calculate the mapping from running time to UTC + // calculate pdt_base_utc for each segment for !pdt_follows_pipeline_clock + // when pdt_follows_pipeline_clock is set, we calculate the base time every time + // this avoids the drift between pdt tag and external clock (if gst clock has skew w.r.t external clock) + if context.pdt_base_utc.is_none() || !settings.pdt_follows_pipeline_clock { + let obj = self.obj(); + let now_utc = Utc::now(); + let now_gst = obj.clock().unwrap().time().unwrap(); + let pts_clock_time = running_time + obj.base_time().unwrap(); + + let diff = now_gst.nseconds() as i64 - pts_clock_time.nseconds() as i64; + let pts_utc = now_utc + .checked_sub_signed(Duration::nanoseconds(diff)) + .expect("offsetting the utc with gstreamer clock-diff overflow"); + + context.pdt_base_utc = Some(pts_utc); + } + + if settings.enable_program_date_time { + // Add the diff of running time to UTC time + // date_time = first_segment_utc + (current_seg_running_time - first_seg_running_time) + let date_time = + context + .pdt_base_utc + .unwrap() + .checked_add_signed(Duration::nanoseconds( + running_time + .opt_checked_sub(context.pdt_base_running_time) + .unwrap() + .unwrap() + .nseconds() as i64, + )); + + if let Some(date_time) = date_time { + segment.program_date_time = Some(date_time.into()); + } + } + } + + context.playlist.add_segment(segment); + + if context.playlist.is_type_undefined() { + context.old_segment_locations.push(location.to_string()); + } + + self.write_playlist(context) + } + + fn write_playlist( + &self, + context: &mut PlaylistContext, + ) -> Result { + gst::info!(CAT, imp: self, "Preparing to write new playlist, COUNT {}", context.playlist.len()); + + context + .playlist + .update_playlist_state(context.playlist_length as usize); + + // Acquires the playlist file handle so we can update it with new content. By default, this + // is expected to be the same file every time. + let mut playlist_stream = self + .obj() + .emit_by_name::>( + SIGNAL_GET_PLAYLIST_STREAM, + &[&context.playlist_location], + ) + .ok_or_else(|| { + gst::error!( + CAT, + imp: self, + "Could not get stream to write playlist content", + ); + gst::FlowError::Error + })? + .into_write(); + + context + .playlist + .write_to(&mut playlist_stream) + .map_err(|err| { + gst::error!( + CAT, + imp: self, + "Could not write new playlist: {}", + err.to_string() + ); + gst::FlowError::Error + })?; + playlist_stream.flush().map_err(|err| { + gst::error!( + CAT, + imp: self, + "Could not flush playlist: {}", + err.to_string() + ); + gst::FlowError::Error + })?; + + if context.playlist.is_type_undefined() && context.max_num_segment_files > 0 { + // Cleanup old segments from filesystem + while context.old_segment_locations.len() > context.max_num_segment_files { + let old_segment_location = context.old_segment_locations.remove(0); + if !self + .obj() + .emit_by_name::(SIGNAL_DELETE_FRAGMENT, &[&old_segment_location]) + { + gst::error!(CAT, imp: self, "Could not delete fragment"); + } + } + } + + gst::debug!(CAT, imp: self, "Wrote new playlist file!"); + Ok(gst::FlowSuccess::Ok) + } + + pub fn new_file_stream

(&self, location: &P) -> Result + where + P: AsRef, + { + let file = fs::File::create(location).map_err(move |err| { + let error_msg = gst::error_msg!( + gst::ResourceError::OpenWrite, + [ + "Could not open file {} for writing: {}", + location.as_ref().to_str().unwrap(), + err.to_string(), + ] + ); + self.post_error_message(error_msg); + err.to_string() + })?; + Ok(gio::WriteOutputStream::new(file).upcast()) + } + + fn delete_fragment

(&self, location: &P) + where + P: AsRef, + { + let _ = fs::remove_file(location).map_err(|err| { + gst::warning!( + CAT, + imp: self, + "Could not delete segment file: {}", + err.to_string() + ); + }); + } +} diff --git a/net/hlssink3/src/hlscmafsink/imp.rs b/net/hlssink3/src/hlscmafsink/imp.rs new file mode 100644 index 00000000..f98baf59 --- /dev/null +++ b/net/hlssink3/src/hlscmafsink/imp.rs @@ -0,0 +1,527 @@ +// Copyright (C) 2023 Seungha Yang +// +// This Source Code Form is subject to the terms of the Mozilla Public License, v2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at +// . +// +// SPDX-License-Identifier: MPL-2.0 + +use crate::basesink::HlsBaseSinkImpl; +use crate::hlssink3::HlsSink3PlaylistType; +use crate::playlist::Playlist; +use crate::HlsBaseSink; +use gio::prelude::*; +use gst::glib; +use gst::prelude::*; +use gst::subclass::prelude::*; +use m3u8_rs::{MediaPlaylist, MediaPlaylistType, MediaSegment}; +use once_cell::sync::Lazy; +use std::io::Write; +use std::sync::Mutex; + +const DEFAULT_INIT_LOCATION: &str = "init%05d.mp4"; +const DEFAULT_CMAF_LOCATION: &str = "segment%05d.m4s"; +const DEFAULT_TARGET_DURATION: u32 = 15; +const DEFAULT_PLAYLIST_TYPE: HlsSink3PlaylistType = HlsSink3PlaylistType::Unspecified; +const DEFAULT_SYNC: bool = true; +const DEFAULT_LATENCY: gst::ClockTime = + gst::ClockTime::from_mseconds((DEFAULT_TARGET_DURATION * 500) as u64); +const SIGNAL_GET_INIT_STREAM: &str = "get-init-stream"; + +static CAT: Lazy = Lazy::new(|| { + gst::DebugCategory::new( + "hlscmafsink", + gst::DebugColorFlags::empty(), + Some("HLS CMAF sink"), + ) +}); + +macro_rules! base_imp { + ($i:expr) => { + $i.obj().upcast_ref::().imp() + }; +} + +struct HlsCmafSinkSettings { + init_location: String, + location: String, + target_duration: u32, + playlist_type: Option, + sync: bool, + latency: gst::ClockTime, + + cmafmux: gst::Element, + appsink: gst_app::AppSink, +} + +impl Default for HlsCmafSinkSettings { + fn default() -> Self { + let cmafmux = gst::ElementFactory::make("cmafmux") + .name("muxer") + .property( + "fragment-duration", + gst::ClockTime::from_seconds(DEFAULT_TARGET_DURATION as u64), + ) + .property("latency", DEFAULT_LATENCY) + .build() + .expect("Could not make element cmafmux"); + let appsink = gst_app::AppSink::builder() + .buffer_list(true) + .sync(DEFAULT_SYNC) + .name("sink") + .build(); + + Self { + init_location: String::from(DEFAULT_INIT_LOCATION), + location: String::from(DEFAULT_CMAF_LOCATION), + target_duration: DEFAULT_TARGET_DURATION, + playlist_type: None, + sync: DEFAULT_SYNC, + latency: DEFAULT_LATENCY, + cmafmux, + appsink, + } + } +} + +#[derive(Default)] +struct HlsCmafSinkState { + init_idx: u32, + segment_idx: u32, + init_segment: Option, + new_header: bool, +} + +#[derive(Default)] +pub struct HlsCmafSink { + settings: Mutex, + state: Mutex, +} + +#[glib::object_subclass] +impl ObjectSubclass for HlsCmafSink { + const NAME: &'static str = "GstHlsCmafSink"; + type Type = super::HlsCmafSink; + type ParentType = HlsBaseSink; +} + +impl ObjectImpl for HlsCmafSink { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecString::builder("init-location") + .nick("Init Location") + .blurb("Location of the init fragment file to write") + .default_value(Some(DEFAULT_INIT_LOCATION)) + .build(), + glib::ParamSpecString::builder("location") + .nick("Location") + .blurb("Location of the fragment file to write") + .default_value(Some(DEFAULT_CMAF_LOCATION)) + .build(), + glib::ParamSpecUInt::builder("target-duration") + .nick("Target duration") + .blurb("The target duration in seconds of a segment/file. (0 - disabled, useful for management of segment duration by the streaming server)") + .default_value(DEFAULT_TARGET_DURATION) + .mutable_ready() + .build(), + glib::ParamSpecEnum::builder_with_default("playlist-type", DEFAULT_PLAYLIST_TYPE) + .nick("Playlist Type") + .blurb("The type of the playlist to use. When VOD type is set, the playlist will be live until the pipeline ends execution.") + .mutable_ready() + .build(), + glib::ParamSpecBoolean::builder("sync") + .nick("Sync") + .blurb("Sync on the clock") + .default_value(DEFAULT_SYNC) + .build(), + glib::ParamSpecUInt64::builder("latency") + .nick("Latency") + .blurb( + "Additional latency to allow upstream to take longer to \ + produce buffers for the current position (in nanoseconds)", + ) + .maximum(i64::MAX as u64) + .default_value(DEFAULT_LATENCY.nseconds()) + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let mut settings = self.settings.lock().unwrap(); + match pspec.name() { + "init-location" => { + settings.init_location = value + .get::>() + .expect("type checked upstream") + .unwrap_or_else(|| DEFAULT_INIT_LOCATION.into()); + } + "location" => { + settings.location = value + .get::>() + .expect("type checked upstream") + .unwrap_or_else(|| DEFAULT_CMAF_LOCATION.into()); + } + "target-duration" => { + settings.target_duration = value.get().expect("type checked upstream"); + settings.cmafmux.set_property( + "fragment-duration", + gst::ClockTime::from_seconds(settings.target_duration as u64), + ); + } + "playlist-type" => { + settings.playlist_type = value + .get::() + .expect("type checked upstream") + .into(); + } + "sync" => { + settings.sync = value.get().expect("type checked upstream"); + settings.appsink.set_property("sync", settings.sync); + } + "latency" => { + settings.latency = value.get().expect("type checked upstream"); + settings.cmafmux.set_property("latency", settings.latency); + } + _ => unimplemented!(), + }; + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let settings = self.settings.lock().unwrap(); + match pspec.name() { + "init-location" => settings.init_location.to_value(), + "location" => settings.location.to_value(), + "target-duration" => settings.target_duration.to_value(), + "playlist-type" => { + let playlist_type: HlsSink3PlaylistType = settings.playlist_type.as_ref().into(); + playlist_type.to_value() + } + "sync" => settings.sync.to_value(), + "latency" => settings.latency.to_value(), + _ => unimplemented!(), + } + } + + fn signals() -> &'static [glib::subclass::Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![glib::subclass::Signal::builder(SIGNAL_GET_INIT_STREAM) + .param_types([String::static_type()]) + .return_type::>() + .class_handler(|_, args| { + let elem = args[0].get::().expect("signal arg"); + let init_location = args[1].get::().expect("signal arg"); + let imp = elem.imp(); + + Some(imp.new_file_stream(&init_location).ok().to_value()) + }) + .accumulator(|_hint, ret, value| { + // First signal handler wins + *ret = value.clone(); + false + }) + .build()] + }); + + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + let settings = self.settings.lock().unwrap(); + + obj.add_many([&settings.cmafmux, settings.appsink.upcast_ref()]) + .unwrap(); + settings.cmafmux.link(&settings.appsink).unwrap(); + + let sinkpad = settings.cmafmux.static_pad("sink").unwrap(); + let gpad = gst::GhostPad::with_target(&sinkpad).unwrap(); + + obj.add_pad(&gpad).unwrap(); + + let self_weak = self.downgrade(); + settings.appsink.set_callbacks( + gst_app::AppSinkCallbacks::builder() + .new_sample(move |sink| { + let Some(imp) = self_weak.upgrade() else { + return Err(gst::FlowError::Eos); + }; + + let sample = sink.pull_sample().map_err(|_| gst::FlowError::Eos)?; + imp.on_new_sample(sample) + }) + .build(), + ); + } +} + +impl GstObjectImpl for HlsCmafSink {} + +impl ElementImpl for HlsCmafSink { + fn metadata() -> Option<&'static gst::subclass::ElementMetadata> { + static ELEMENT_METADATA: Lazy = Lazy::new(|| { + gst::subclass::ElementMetadata::new( + "HTTP Live Streaming CMAF Sink", + "Sink/Muxer", + "HTTP Live Streaming CMAF Sink", + "Seungha Yang ", + ) + }); + + Some(&*ELEMENT_METADATA) + } + + fn pad_templates() -> &'static [gst::PadTemplate] { + static PAD_TEMPLATES: Lazy> = Lazy::new(|| { + let pad_template = gst::PadTemplate::new( + "sink", + gst::PadDirection::Sink, + gst::PadPresence::Always, + &[ + gst::Structure::builder("video/x-h264") + .field("stream-format", gst::List::new(["avc", "avc3"])) + .field("alignment", "au") + .field("width", gst::IntRange::new(1, u16::MAX as i32)) + .field("height", gst::IntRange::new(1, u16::MAX as i32)) + .build(), + gst::Structure::builder("video/x-h265") + .field("stream-format", gst::List::new(["hvc1", "hev1"])) + .field("alignment", "au") + .field("width", gst::IntRange::new(1, u16::MAX as i32)) + .field("height", gst::IntRange::new(1, u16::MAX as i32)) + .build(), + gst::Structure::builder("audio/mpeg") + .field("mpegversion", 4i32) + .field("stream-format", "raw") + .field("channels", gst::IntRange::new(1, u16::MAX as i32)) + .field("rate", gst::IntRange::new(1, i32::MAX)) + .build(), + ] + .into_iter() + .collect::(), + ) + .unwrap(); + + vec![pad_template] + }); + + PAD_TEMPLATES.as_ref() + } + + fn change_state( + &self, + transition: gst::StateChange, + ) -> Result { + if transition == gst::StateChange::ReadyToPaused { + let (target_duration, playlist_type, segment_template) = { + let settings = self.settings.lock().unwrap(); + ( + settings.target_duration, + settings.playlist_type.clone(), + settings.location.clone(), + ) + }; + + let playlist = self.start(target_duration, playlist_type); + base_imp!(self).open_playlist(playlist, segment_template); + } + + self.parent_change_state(transition) + } +} + +impl BinImpl for HlsCmafSink {} + +impl HlsBaseSinkImpl for HlsCmafSink {} + +impl HlsCmafSink { + fn start(&self, target_duration: u32, playlist_type: Option) -> Playlist { + gst::info!(CAT, imp: self, "Starting"); + + let mut state = self.state.lock().unwrap(); + *state = HlsCmafSinkState::default(); + + let (turn_vod, playlist_type) = if playlist_type == Some(MediaPlaylistType::Vod) { + (true, Some(MediaPlaylistType::Event)) + } else { + (false, playlist_type) + }; + + let playlist = MediaPlaylist { + version: Some(6), + target_duration: target_duration as f32, + playlist_type, + independent_segments: true, + ..Default::default() + }; + + Playlist::new(playlist, turn_vod, true) + } + + fn on_init_segment(&self) -> Result, String> { + let settings = self.settings.lock().unwrap(); + let mut state = self.state.lock().unwrap(); + let location = match sprintf::sprintf!(&settings.init_location, state.init_idx) { + Ok(location) => location, + Err(err) => { + gst::error!( + CAT, + imp: self, + "Couldn't build file name, err: {:?}", err, + ); + return Err(String::from("Invalid init segment file pattern")); + } + }; + + let stream = self + .obj() + .emit_by_name::>(SIGNAL_GET_INIT_STREAM, &[&location]) + .ok_or_else(|| String::from("Error while getting fragment stream"))? + .into_write(); + + let uri = base_imp!(self).get_segment_uri(&location); + + state.init_segment = Some(m3u8_rs::Map { + uri, + ..Default::default() + }); + state.new_header = true; + state.init_idx += 1; + + Ok(stream) + } + + fn on_new_fragment( + &self, + ) -> Result<(gio::OutputStreamWrite, String), String> { + let mut state = self.state.lock().unwrap(); + let (stream, location) = base_imp!(self) + .get_fragment_stream(state.segment_idx) + .ok_or_else(|| String::from("Error while getting fragment stream"))?; + + state.segment_idx += 1; + + Ok((stream.into_write(), location)) + } + + fn add_segment( + &self, + duration: f32, + running_time: Option, + location: String, + ) -> Result { + let uri = base_imp!(self).get_segment_uri(&location); + let mut state = self.state.lock().unwrap(); + + let map = if state.new_header { + state.new_header = false; + state.init_segment.clone() + } else { + None + }; + + base_imp!(self).add_segment( + &location, + running_time, + MediaSegment { + uri, + duration, + map, + ..Default::default() + }, + ) + } + + fn on_new_sample(&self, sample: gst::Sample) -> Result { + let mut buffer_list = sample.buffer_list_owned().unwrap(); + let mut first = buffer_list.get(0).unwrap(); + + if first + .flags() + .contains(gst::BufferFlags::DISCONT | gst::BufferFlags::HEADER) + { + let mut stream = self.on_init_segment().map_err(|err| { + gst::error!( + CAT, + imp: self, + "Couldn't get output stream for init segment, {err}", + ); + gst::FlowError::Error + })?; + + let map = first.map_readable().unwrap(); + stream.write(&map).map_err(|_| { + gst::error!( + CAT, + imp: self, + "Couldn't write init segment to output stream", + ); + gst::FlowError::Error + })?; + + stream.flush().map_err(|_| { + gst::error!( + CAT, + imp: self, + "Couldn't flush output stream", + ); + gst::FlowError::Error + })?; + + drop(map); + + buffer_list.make_mut().remove(0, 1); + if buffer_list.is_empty() { + return Ok(gst::FlowSuccess::Ok); + } + + first = buffer_list.get(0).unwrap(); + } + + let segment = sample + .segment() + .unwrap() + .downcast_ref::() + .unwrap(); + let running_time = segment.to_running_time(first.pts().unwrap()); + let dur = first.duration().unwrap(); + + let (mut stream, location) = self.on_new_fragment().map_err(|err| { + gst::error!( + CAT, + imp: self, + "Couldn't get output stream for segment, {err}", + ); + gst::FlowError::Error + })?; + + for buffer in &*buffer_list { + let map = buffer.map_readable().unwrap(); + + stream.write(&map).map_err(|_| { + gst::error!( + CAT, + imp: self, + "Couldn't write segment to output stream", + ); + gst::FlowError::Error + })?; + } + + stream.flush().map_err(|_| { + gst::error!( + CAT, + imp: self, + "Couldn't flush output stream", + ); + gst::FlowError::Error + })?; + + self.add_segment(dur.mseconds() as f32 / 1_000f32, running_time, location) + } +} diff --git a/net/hlssink3/src/hlscmafsink/mod.rs b/net/hlssink3/src/hlscmafsink/mod.rs new file mode 100644 index 00000000..5875a207 --- /dev/null +++ b/net/hlssink3/src/hlscmafsink/mod.rs @@ -0,0 +1,34 @@ +// Copyright (C) 2023 Seungha Yang +// +// This Source Code Form is subject to the terms of the Mozilla Public License, v2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at +// . +// +// SPDX-License-Identifier: MPL-2.0 +#![allow(clippy::non_send_fields_in_send_ty, unused_doc_comments)] + +use crate::HlsBaseSink; +/** + * plugin-hlssink3: + * + * Since: plugins-rs-0.8.0 + */ +use gst::glib; +use gst::prelude::*; + +mod imp; + +glib::wrapper! { + pub struct HlsCmafSink(ObjectSubclass) @extends HlsBaseSink, gst::Bin, gst::Element, gst::Object; +} + +pub fn register(plugin: &gst::Plugin) -> Result<(), glib::BoolError> { + gst::Element::register( + Some(plugin), + "hlscmafsink", + gst::Rank::NONE, + HlsCmafSink::static_type(), + )?; + + Ok(()) +} diff --git a/net/hlssink3/src/hlssink3/imp.rs b/net/hlssink3/src/hlssink3/imp.rs new file mode 100644 index 00000000..4cc8ecab --- /dev/null +++ b/net/hlssink3/src/hlssink3/imp.rs @@ -0,0 +1,581 @@ +// Copyright (C) 2021 Rafael Caricio +// Copyright (C) 2023 Seungha Yang +// +// This Source Code Form is subject to the terms of the Mozilla Public License, v2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at +// . +// +// SPDX-License-Identifier: MPL-2.0 + +use crate::basesink::HlsBaseSinkImpl; +use crate::hlssink3::HlsSink3PlaylistType; +use crate::playlist::Playlist; +use crate::HlsBaseSink; +use gio::prelude::*; +use gst::glib; +use gst::prelude::*; +use gst::subclass::prelude::*; +use m3u8_rs::{MediaPlaylist, MediaPlaylistType, MediaSegment}; +use once_cell::sync::Lazy; +use std::sync::Mutex; + +const DEFAULT_TS_LOCATION: &str = "segment%05d.ts"; +const DEFAULT_TARGET_DURATION: u32 = 15; +const DEFAULT_PLAYLIST_TYPE: HlsSink3PlaylistType = HlsSink3PlaylistType::Unspecified; +const DEFAULT_I_FRAMES_ONLY_PLAYLIST: bool = false; +const DEFAULT_SEND_KEYFRAME_REQUESTS: bool = true; + +static CAT: Lazy = Lazy::new(|| { + gst::DebugCategory::new("hlssink3", gst::DebugColorFlags::empty(), Some("HLS sink")) +}); + +macro_rules! base_imp { + ($i:expr) => { + $i.obj().upcast_ref::().imp() + }; +} + +impl From for Option { + fn from(pl_type: HlsSink3PlaylistType) -> Self { + use HlsSink3PlaylistType::*; + match pl_type { + Unspecified => None, + Event => Some(MediaPlaylistType::Event), + Vod => Some(MediaPlaylistType::Vod), + } + } +} + +impl From> for HlsSink3PlaylistType { + fn from(inner_pl_type: Option<&MediaPlaylistType>) -> Self { + use HlsSink3PlaylistType::*; + match inner_pl_type { + None | Some(MediaPlaylistType::Other(_)) => Unspecified, + Some(MediaPlaylistType::Event) => Event, + Some(MediaPlaylistType::Vod) => Vod, + } + } +} + +struct HlsSink3Settings { + location: String, + target_duration: u32, + playlist_type: Option, + i_frames_only: bool, + send_keyframe_requests: bool, + + splitmuxsink: gst::Element, + giostreamsink: gst::Element, + video_sink: bool, + audio_sink: bool, +} + +impl Default for HlsSink3Settings { + fn default() -> Self { + let muxer = gst::ElementFactory::make("mpegtsmux") + .name("mpeg-ts_mux") + .build() + .expect("Could not make element mpegtsmux"); + let giostreamsink = gst::ElementFactory::make("giostreamsink") + .name("giostream_sink") + .build() + .expect("Could not make element giostreamsink"); + let splitmuxsink = gst::ElementFactory::make("splitmuxsink") + .name("split_mux_sink") + .property("muxer", &muxer) + .property("reset-muxer", false) + .property("send-keyframe-requests", DEFAULT_SEND_KEYFRAME_REQUESTS) + .property( + "max-size-time", + gst::ClockTime::from_seconds(DEFAULT_TARGET_DURATION as u64), + ) + .property("sink", &giostreamsink) + .build() + .expect("Could not make element splitmuxsink"); + + // giostreamsink doesn't let go of its stream until the element is finalized, which might + // be too late for the calling application. Let's try to force it to close while tearing + // down the pipeline. + if giostreamsink.has_property("close-on-stop", Some(bool::static_type())) { + giostreamsink.set_property("close-on-stop", true); + } else { + gst::warning!( + CAT, + "hlssink3 may sometimes fail to write out the final playlist update. This can be fixed by using giostreamsink from GStreamer 1.24 or later." + ) + } + + Self { + location: String::from(DEFAULT_TS_LOCATION), + target_duration: DEFAULT_TARGET_DURATION, + playlist_type: None, + send_keyframe_requests: DEFAULT_SEND_KEYFRAME_REQUESTS, + i_frames_only: DEFAULT_I_FRAMES_ONLY_PLAYLIST, + + splitmuxsink, + giostreamsink, + video_sink: false, + audio_sink: false, + } + } +} + +#[derive(Default)] +struct HlsSink3State { + fragment_opened_at: Option, + fragment_running_time: Option, + current_segment_location: Option, +} + +#[derive(Default)] +pub struct HlsSink3 { + settings: Mutex, + state: Mutex, +} + +#[glib::object_subclass] +impl ObjectSubclass for HlsSink3 { + const NAME: &'static str = "GstHlsSink3"; + type Type = super::HlsSink3; + type ParentType = HlsBaseSink; +} + +impl ObjectImpl for HlsSink3 { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecString::builder("location") + .nick("File Location") + .blurb("Location of the file to write") + .default_value(Some(DEFAULT_TS_LOCATION)) + .build(), + glib::ParamSpecUInt::builder("target-duration") + .nick("Target duration") + .blurb("The target duration in seconds of a segment/file. (0 - disabled, useful for management of segment duration by the streaming server)") + .default_value(DEFAULT_TARGET_DURATION) + .build(), + glib::ParamSpecEnum::builder_with_default("playlist-type", DEFAULT_PLAYLIST_TYPE) + .nick("Playlist Type") + .blurb("The type of the playlist to use. When VOD type is set, the playlist will be live until the pipeline ends execution.") + .build(), + glib::ParamSpecBoolean::builder("i-frames-only") + .nick("I-Frames only playlist") + .blurb("Each video segments is single iframe, So put EXT-X-I-FRAMES-ONLY tag in the playlist") + .default_value(DEFAULT_I_FRAMES_ONLY_PLAYLIST) + .build(), + glib::ParamSpecBoolean::builder("send-keyframe-requests") + .nick("Send Keyframe Requests") + .blurb("Send keyframe requests to ensure correct fragmentation. If this is disabled then the input must have keyframes in regular intervals.") + .default_value(DEFAULT_SEND_KEYFRAME_REQUESTS) + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let mut settings = self.settings.lock().unwrap(); + match pspec.name() { + "location" => { + settings.location = value + .get::>() + .expect("type checked upstream") + .unwrap_or_else(|| DEFAULT_TS_LOCATION.into()); + settings + .splitmuxsink + .set_property("location", &settings.location); + } + "target-duration" => { + settings.target_duration = value.get().expect("type checked upstream"); + settings.splitmuxsink.set_property( + "max-size-time", + gst::ClockTime::from_seconds(settings.target_duration as u64), + ); + } + "playlist-type" => { + settings.playlist_type = value + .get::() + .expect("type checked upstream") + .into(); + } + "i-frames-only" => { + settings.i_frames_only = value.get().expect("type checked upstream"); + if settings.i_frames_only && settings.audio_sink { + gst::element_error!( + self.obj(), + gst::StreamError::WrongType, + ("Invalid configuration"), + ["Audio not allowed for i-frames-only-stream"] + ); + } + } + "send-keyframe-requests" => { + settings.send_keyframe_requests = value.get().expect("type checked upstream"); + settings + .splitmuxsink + .set_property("send-keyframe-requests", settings.send_keyframe_requests); + } + _ => unimplemented!(), + }; + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let settings = self.settings.lock().unwrap(); + match pspec.name() { + "location" => settings.location.to_value(), + "target-duration" => settings.target_duration.to_value(), + "playlist-type" => { + let playlist_type: HlsSink3PlaylistType = settings.playlist_type.as_ref().into(); + playlist_type.to_value() + } + "i-frames-only" => settings.i_frames_only.to_value(), + "send-keyframe-requests" => settings.send_keyframe_requests.to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + let settings = self.settings.lock().unwrap(); + + obj.add(&settings.splitmuxsink).unwrap(); + settings + .splitmuxsink + .connect("format-location-full", false, { + let imp_weak = self.downgrade(); + move |args| { + let Some(imp) = imp_weak.upgrade() else { + return Some(None::.to_value()); + }; + let fragment_id = args[1].get::().unwrap(); + gst::info!(CAT, imp: imp, "Got fragment-id: {}", fragment_id); + + let sample = args[2].get::().unwrap(); + let buffer = sample.buffer(); + let running_time = if let Some(buffer) = buffer { + let segment = sample + .segment() + .expect("segment not available") + .downcast_ref::() + .expect("no time segment"); + segment.to_running_time(buffer.pts().unwrap()) + } else { + gst::warning!( + CAT, + imp: imp, + "buffer null for fragment-id: {}", + fragment_id + ); + None + }; + + match imp.on_format_location(fragment_id, running_time) { + Ok(segment_location) => Some(segment_location.to_value()), + Err(err) => { + gst::error!(CAT, imp: imp, "on format-location handler: {}", err); + Some("unknown_segment".to_value()) + } + } + } + }); + } +} + +impl GstObjectImpl for HlsSink3 {} + +impl ElementImpl for HlsSink3 { + fn metadata() -> Option<&'static gst::subclass::ElementMetadata> { + static ELEMENT_METADATA: Lazy = Lazy::new(|| { + gst::subclass::ElementMetadata::new( + "HTTP Live Streaming sink", + "Sink/Muxer", + "HTTP Live Streaming sink", + "Alessandro Decina , \ + Sebastian Dröge , \ + Rafael Caricio ", + ) + }); + + Some(&*ELEMENT_METADATA) + } + + fn pad_templates() -> &'static [gst::PadTemplate] { + static PAD_TEMPLATES: Lazy> = Lazy::new(|| { + let caps = gst::Caps::new_any(); + let video_pad_template = gst::PadTemplate::new( + "video", + gst::PadDirection::Sink, + gst::PadPresence::Request, + &caps, + ) + .unwrap(); + + let caps = gst::Caps::new_any(); + let audio_pad_template = gst::PadTemplate::new( + "audio", + gst::PadDirection::Sink, + gst::PadPresence::Request, + &caps, + ) + .unwrap(); + + vec![video_pad_template, audio_pad_template] + }); + + PAD_TEMPLATES.as_ref() + } + + fn change_state( + &self, + transition: gst::StateChange, + ) -> Result { + if transition == gst::StateChange::ReadyToPaused { + let (target_duration, playlist_type, i_frames_only, segment_template) = { + let settings = self.settings.lock().unwrap(); + ( + settings.target_duration, + settings.playlist_type.clone(), + settings.i_frames_only, + settings.location.clone(), + ) + }; + + let playlist = self.start(target_duration, playlist_type, i_frames_only); + base_imp!(self).open_playlist(playlist, segment_template); + } + + self.parent_change_state(transition) + } + + fn request_new_pad( + &self, + templ: &gst::PadTemplate, + _name: Option<&str>, + _caps: Option<&gst::Caps>, + ) -> Option { + let mut settings = self.settings.lock().unwrap(); + match templ.name_template() { + "audio" => { + if settings.audio_sink { + gst::debug!( + CAT, + imp: self, + "requested_new_pad: audio pad is already set" + ); + return None; + } + if settings.i_frames_only { + gst::element_error!( + self.obj(), + gst::StreamError::WrongType, + ("Invalid configuration"), + ["Audio not allowed for i-frames-only-stream"] + ); + return None; + } + + let peer_pad = settings.splitmuxsink.request_pad_simple("audio_0").unwrap(); + let sink_pad = gst::GhostPad::from_template_with_target(templ, &peer_pad).unwrap(); + self.obj().add_pad(&sink_pad).unwrap(); + sink_pad.set_active(true).unwrap(); + settings.audio_sink = true; + + Some(sink_pad.upcast()) + } + "video" => { + if settings.video_sink { + gst::debug!( + CAT, + imp: self, + "requested_new_pad: video pad is already set" + ); + return None; + } + let peer_pad = settings.splitmuxsink.request_pad_simple("video").unwrap(); + + let sink_pad = gst::GhostPad::from_template_with_target(templ, &peer_pad).unwrap(); + self.obj().add_pad(&sink_pad).unwrap(); + sink_pad.set_active(true).unwrap(); + settings.video_sink = true; + + Some(sink_pad.upcast()) + } + other_name => { + gst::debug!( + CAT, + imp: self, + "requested_new_pad: name \"{}\" is not audio or video", + other_name + ); + None + } + } + } + + fn release_pad(&self, pad: &gst::Pad) { + let mut settings = self.settings.lock().unwrap(); + + if !settings.audio_sink && !settings.video_sink { + return; + } + + let ghost_pad = pad.downcast_ref::().unwrap(); + if let Some(peer) = ghost_pad.target() { + settings.splitmuxsink.release_request_pad(&peer); + } + + pad.set_active(false).unwrap(); + self.obj().remove_pad(pad).unwrap(); + + if "audio" == ghost_pad.name() { + settings.audio_sink = false; + } else { + settings.video_sink = false; + } + } +} + +impl BinImpl for HlsSink3 { + #[allow(clippy::single_match)] + fn handle_message(&self, msg: gst::Message) { + use gst::MessageView; + + match msg.view() { + MessageView::Element(msg) => { + let event_is_from_splitmuxsink = { + let settings = self.settings.lock().unwrap(); + + msg.src() == Some(settings.splitmuxsink.upcast_ref()) + }; + if !event_is_from_splitmuxsink { + return; + } + + let s = msg.structure().unwrap(); + match s.name().as_str() { + "splitmuxsink-fragment-opened" => { + if let Ok(new_fragment_opened_at) = s.get::("running-time") + { + let mut state = self.state.lock().unwrap(); + state.fragment_opened_at = Some(new_fragment_opened_at); + } + } + "splitmuxsink-fragment-closed" => { + let s = msg.structure().unwrap(); + if let Ok(fragment_closed_at) = s.get::("running-time") { + self.on_fragment_closed(fragment_closed_at); + } + } + _ => {} + } + } + _ => self.parent_handle_message(msg), + } + } +} + +impl HlsBaseSinkImpl for HlsSink3 {} + +impl HlsSink3 { + fn start( + &self, + target_duration: u32, + playlist_type: Option, + i_frames_only: bool, + ) -> Playlist { + gst::info!(CAT, imp: self, "Starting"); + + let mut state = self.state.lock().unwrap(); + *state = HlsSink3State::default(); + + let (turn_vod, playlist_type) = if playlist_type == Some(MediaPlaylistType::Vod) { + (true, Some(MediaPlaylistType::Event)) + } else { + (false, playlist_type) + }; + + let playlist = MediaPlaylist { + version: if i_frames_only { Some(4) } else { Some(3) }, + target_duration: target_duration as f32, + playlist_type, + i_frames_only, + ..Default::default() + }; + + Playlist::new(playlist, turn_vod, false) + } + + fn on_format_location( + &self, + fragment_id: u32, + running_time: Option, + ) -> Result { + gst::info!( + CAT, + imp: self, + "Starting the formatting of the fragment-id: {}", + fragment_id + ); + + let (fragment_stream, segment_file_location) = base_imp!(self) + .get_fragment_stream(fragment_id) + .ok_or_else(|| String::from("Error while getting fragment stream"))?; + + let mut state = self.state.lock().unwrap(); + state.current_segment_location = Some(segment_file_location.clone()); + state.fragment_running_time = running_time; + + let settings = self.settings.lock().unwrap(); + settings + .giostreamsink + .set_property("stream", &fragment_stream); + + gst::info!( + CAT, + imp: self, + "New segment location: {:?}", + state.current_segment_location.as_ref() + ); + + Ok(segment_file_location) + } + + fn on_fragment_closed(&self, closed_at: gst::ClockTime) { + let mut state = self.state.lock().unwrap(); + let location = match state.current_segment_location.take() { + Some(location) => location, + None => { + gst::error!(CAT, imp: self, "Unknown segment location"); + return; + } + }; + + let opened_at = match state.fragment_opened_at.take() { + Some(opened_at) => opened_at, + None => { + gst::error!(CAT, imp: self, "Unknown segment duration"); + return; + } + }; + + let duration = ((closed_at - opened_at).mseconds() as f32) / 1_000f32; + let running_time = state.fragment_running_time; + drop(state); + + let obj = self.obj(); + let base_imp = obj.upcast_ref::().imp(); + let uri = base_imp.get_segment_uri(&location); + let _ = base_imp.add_segment( + &location, + running_time, + MediaSegment { + uri, + duration, + ..Default::default() + }, + ); + } +} diff --git a/net/hlssink3/src/hlssink3/mod.rs b/net/hlssink3/src/hlssink3/mod.rs new file mode 100644 index 00000000..2aec5c7e --- /dev/null +++ b/net/hlssink3/src/hlssink3/mod.rs @@ -0,0 +1,63 @@ +// Copyright (C) 2021 Rafael Caricio +// +// This Source Code Form is subject to the terms of the Mozilla Public License, v2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at +// . +// +// SPDX-License-Identifier: MPL-2.0 +#![allow(clippy::non_send_fields_in_send_ty, unused_doc_comments)] + +use crate::HlsBaseSink; +/** + * plugin-hlssink3: + * + * Since: plugins-rs-0.8.0 + */ +use gst::glib; +use gst::prelude::*; + +mod imp; + +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Clone, Copy, glib::Enum)] +#[repr(u32)] +#[enum_type(name = "GstHlsSink3PlaylistType")] +#[non_exhaustive] +pub enum HlsSink3PlaylistType { + #[enum_value( + name = "Unspecified: The tag `#EXT-X-PLAYLIST-TYPE` won't be present in the playlist during the pipeline processing.", + nick = "unspecified" + )] + Unspecified = 0, + + #[enum_value( + name = "Event: No segments will be removed from the playlist. At the end of the processing, the tag `#EXT-X-ENDLIST` is added to the playlist. The tag `#EXT-X-PLAYLIST-TYPE:EVENT` will be present in the playlist.", + nick = "event" + )] + Event = 1, + + #[enum_value( + name = "Vod: The playlist behaves like the `event` option (a live event), but at the end of the processing, the playlist will be set to `#EXT-X-PLAYLIST-TYPE:VOD`.", + nick = "vod" + )] + Vod = 2, +} + +glib::wrapper! { + pub struct HlsSink3(ObjectSubclass) @extends HlsBaseSink, gst::Bin, gst::Element, gst::Object; +} + +pub fn register(plugin: &gst::Plugin) -> Result<(), glib::BoolError> { + #[cfg(feature = "doc")] + { + HlsSink3PlaylistType::static_type().mark_as_plugin_api(gst::PluginAPIFlags::empty()); + } + + gst::Element::register( + Some(plugin), + "hlssink3", + gst::Rank::NONE, + HlsSink3::static_type(), + )?; + + Ok(()) +} diff --git a/net/hlssink3/src/hlssink4/imp.rs b/net/hlssink3/src/hlssink4/imp.rs new file mode 100644 index 00000000..ed02d052 --- /dev/null +++ b/net/hlssink3/src/hlssink4/imp.rs @@ -0,0 +1,1474 @@ +// Copyright (C) 2024, asymptotic.io +// Author: Sanchayan Maity +// +// This Source Code Form is subject to the terms of the Mozilla Public License, v2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at +// . +// +// SPDX-License-Identifier: MPL-2.0 + +/* + * `hlssink4` for supporting multi-variant playlist with alternate renditions + * and variant streams. Builds on top of and requires `hlscmafsink`/`hlssink3`. + * + * TODO: + * + * - Support for closed captions + * - Support for WebVTT subtitles + * + * NOT SUPPORTED: + * + * - Muxed audio and video with alternate renditions + * - Simple Media playlist. Use `hlssink3` for the same + */ + +use crate::hlssink4::{HlsSink4AlternativeMediaType, HlsSink4MuxerType, HlsSink4PlaylistType}; +use gio::prelude::*; +use gst::glib; +use gst::prelude::*; +use gst::subclass::prelude::*; +use m3u8_rs::{ + AlternativeMedia, AlternativeMediaType, MasterPlaylist, MediaPlaylistType, VariantStream, +}; +use once_cell::sync::Lazy; +use std::collections::HashMap; +use std::fmt; +use std::fmt::Display; +use std::fs; +use std::fs::File; +use std::path; +use std::str::FromStr; +use std::sync::Mutex; + +const DEFAULT_AUTO_SELECT: bool = false; +const DEFAULT_FORCED: bool = false; +const DEFAULT_I_FRAMES_ONLY_PLAYLIST: bool = false; +const DEFAULT_IS_DEFAULT: bool = false; +const DEFAULT_MAX_NUM_SEGMENT_FILES: u32 = 10; +const DEFAULT_MUXER_TYPE: HlsSink4MuxerType = HlsSink4MuxerType::Cmaf; +const DEFAULT_PLAYLIST_LENGTH: u32 = 5; +const DEFAULT_PLAYLIST_TYPE: HlsSink4PlaylistType = HlsSink4PlaylistType::Unspecified; +const DEFAULT_SEND_KEYFRAME_REQUESTS: bool = true; +const DEFAULT_TARGET_DURATION: u32 = 15; +const DEFAULT_TS_LOCATION: &str = "segment%05d.ts"; +const DEFAULT_INIT_LOCATION: &str = "init%05d.mp4"; +const DEFAULT_CMAF_LOCATION: &str = "segment%05d.m4s"; +const DEFAULT_MASTER_PLAYLIST_LOCATION: &str = "master.m3u8"; + +const SIGNAL_DELETE_FRAGMENT: &str = "delete-fragment"; +const SIGNAL_GET_FRAGMENT_STREAM: &str = "get-fragment-stream"; +const SIGNAL_GET_INIT_STREAM: &str = "get-init-stream"; +const SIGNAL_GET_MASTER_PLAYLIST_STREAM: &str = "get-master-playlist-stream"; +const SIGNAL_GET_PLAYLIST_STREAM: &str = "get-playlist-stream"; + +static CAT: Lazy = Lazy::new(|| { + gst::DebugCategory::new("hlssink4", gst::DebugColorFlags::empty(), Some("HLS sink")) +}); + +impl From for AlternativeMediaType { + fn from(media_type: HlsSink4AlternativeMediaType) -> Self { + match media_type { + HlsSink4AlternativeMediaType::Audio => AlternativeMediaType::Audio, + HlsSink4AlternativeMediaType::Video => AlternativeMediaType::Video, + } + } +} + +impl From for HlsSink4AlternativeMediaType { + fn from(value: AlternativeMediaType) -> Self { + match value { + AlternativeMediaType::Audio => HlsSink4AlternativeMediaType::Audio, + AlternativeMediaType::Video => HlsSink4AlternativeMediaType::Video, + AlternativeMediaType::ClosedCaptions => unimplemented!(), + AlternativeMediaType::Subtitles => unimplemented!(), + AlternativeMediaType::Other(_) => unimplemented!(), + } + } +} + +impl FromStr for HlsSink4AlternativeMediaType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "AUDIO" => Ok(HlsSink4AlternativeMediaType::Audio), + "VIDEO" => Ok(HlsSink4AlternativeMediaType::Video), + "audio" => Ok(HlsSink4AlternativeMediaType::Audio), + "video" => Ok(HlsSink4AlternativeMediaType::Video), + _ => unimplemented!(), + } + } +} + +impl Display for HlsSink4AlternativeMediaType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + match self { + HlsSink4AlternativeMediaType::Audio => "AUDIO", + HlsSink4AlternativeMediaType::Video => "VIDEO", + } + ) + } +} + +impl Display for HlsSink4PlaylistType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + HlsSink4PlaylistType::Unspecified => "unspecified", + HlsSink4PlaylistType::Event => "event", + HlsSink4PlaylistType::Vod => "vod", + } + ) + } +} + +impl From for Option { + fn from(pl_type: HlsSink4PlaylistType) -> Self { + use HlsSink4PlaylistType::*; + match pl_type { + Unspecified => None, + Event => Some(MediaPlaylistType::Event), + Vod => Some(MediaPlaylistType::Vod), + } + } +} + +impl From> for HlsSink4PlaylistType { + fn from(inner_pl_type: Option<&MediaPlaylistType>) -> Self { + use HlsSink4PlaylistType::*; + match inner_pl_type { + Some(MediaPlaylistType::Event) => Event, + Some(MediaPlaylistType::Vod) => Vod, + None | Some(MediaPlaylistType::Other(_)) => Unspecified, + } + } +} + +#[derive(Clone, Default)] +struct AlternateRendition { + media_type: HlsSink4AlternativeMediaType, + /* + * While the URI is optional for an alternate rendition when + * the media type is audio or video, we keep it required here + * because of the way we handle media as non-muxed and each + * media having it's own HLS sink element downstream. + * + * We do not support muxed audio video for renditions. + */ + uri: String, + group_id: String, + language: Option, + name: String, + default: bool, + autoselect: bool, + forced: bool, +} + +impl From<&AlternateRendition> for AlternativeMedia { + fn from(rendition: &AlternateRendition) -> Self { + Self { + media_type: AlternativeMediaType::from(rendition.media_type), + uri: Some(rendition.uri.clone()), + group_id: rendition.group_id.clone(), + language: rendition.language.clone(), + name: rendition.name.clone(), + default: rendition.default, + autoselect: rendition.autoselect, + forced: rendition.forced, + ..Default::default() + } + } +} + +impl From for AlternateRendition { + fn from(s: gst::Structure) -> Self { + AlternateRendition { + media_type: s.get::<&str>("media_type").map_or( + HlsSink4AlternativeMediaType::Audio, + |media| { + HlsSink4AlternativeMediaType::from_str(media).expect("Failed to get media type") + }, + ), + uri: s.get("uri").expect("uri missing in alternate rendition"), + group_id: s + .get("group_id") + .expect("group_id missing in alternate rendition"), + language: s.get("language").unwrap_or(None), + name: s.get("name").expect("name missing in alternate rendition"), + default: s.get("default").unwrap_or(DEFAULT_IS_DEFAULT), + autoselect: s.get("autoselect").unwrap_or(DEFAULT_AUTO_SELECT), + forced: s.get("forced").unwrap_or(DEFAULT_FORCED), + } + } +} + +impl From for gst::Structure { + fn from(obj: AlternateRendition) -> Self { + gst::Structure::builder("pad-settings") + .field("media_type", obj.media_type) + .field("uri", obj.uri) + .field("group_id", obj.group_id) + .field("language", obj.language) + .field("name", obj.name) + .field("default", obj.default) + .field("autoselect", obj.autoselect) + .field("forced", obj.forced) + .build() + } +} + +#[derive(Clone, Debug, Default)] +struct Variant { + /* No effect when using hlscmafsink which is the default */ + is_i_frame: bool, + /* + * For a variant to have muxed audio and video, set the URI on the + * variant pad property of the audio and video pads to be the same. + */ + uri: String, + bandwidth: u64, + codecs: Option, + audio: Option, + video: Option, +} + +impl From for Variant { + fn from(s: gst::Structure) -> Self { + Variant { + is_i_frame: s + .get("is-i-frame") + .unwrap_or(DEFAULT_I_FRAMES_ONLY_PLAYLIST), + uri: s.get("uri").expect("uri missing in variant stream"), + bandwidth: s + .get::("bandwidth") + .expect("bandwidth missing in variant stream") as u64, + audio: s.get("audio").unwrap_or(None), + video: s.get("video").unwrap_or(None), + /* + * This will be constructed from caps when using `hlscmafsink`. + * Needs to set otherwise. + */ + codecs: s.get("codecs").unwrap_or(None), + } + } +} + +impl From for gst::Structure { + fn from(obj: Variant) -> Self { + gst::Structure::builder("variant-stream") + .field("is-i-frame", obj.is_i_frame) + .field("uri", obj.uri) + .field("bandwidth", obj.bandwidth) + .field("codecs", obj.codecs) + .field("audio", obj.audio) + .field("video", obj.video) + .build() + } +} + +impl From<&Variant> for VariantStream { + fn from(variant: &Variant) -> Self { + Self { + is_i_frame: variant.is_i_frame, + uri: variant.uri.clone(), + bandwidth: variant.bandwidth, + codecs: variant.codecs.clone(), + audio: variant.audio.clone(), + video: variant.video.clone(), + ..Default::default() + } + } +} + +impl From for Variant { + fn from(value: VariantStream) -> Self { + Self { + is_i_frame: value.is_i_frame, + uri: value.uri, + bandwidth: value.bandwidth, + codecs: value.codecs, + audio: value.audio, + video: value.video, + } + } +} + +/* Helper functions */ +fn accumulate_codec_caps( + codecs: &mut HashMap>, + caps: gst::Caps, + id: String, +) { + match codecs.get_mut(id.as_str()) { + Some(v) => { + v.push(caps); + /* + * TODO: Should we move to itertools unique? + * + * It is possible to get multiple CAPS event on the pad. We + * rely on writing the master playlist only after CAPS for + * all the pads are known so that the codec string for variant + * can be generated correctly before writing the playlist. In + * case of multiple events, the count can be higher. If one + * cap is a subset of the other drop the subset cap to prevent + * this. + */ + v.dedup_by(|caps1, caps2| caps1.is_subset(caps2)); + } + None => { + let vec = vec![caps]; + codecs.insert(id, vec); + } + } +} + +fn build_codec_string_for_variant( + variant: &Variant, + codecs: &HashMap>, +) -> Result, glib::BoolError> { + /* + * mpegtsmux only accepts stream-format as byte-stream for H264/H265. + * The pbutils helper used relies on codec_data for figuring out the + * profile and level information, however codec_data is absent in the + * case of stream-format being byte-stream. + * + * If the profile and level information are missing from the codecs + * field, clients like hls.js or Video.js which rely on browsers + * built-in HLS support fail to load the media source. This can be + * checked by running something like below in the browser console. + * + * MediaSource.isTypeSupported('video/mp4;codecs=avc1') + * MediaSource.isTypeSupported('video/mp4;codecs=avc1.42000c') + * + * The first one will return a false. + * + * The use of `hlscmafsink` with the muxer type being CMAF by default + * uses the `avc` stream-format where `codec_data` is included and + * the get mime codec helper retrieves the codec string. + * + * If the user opts for MPEGTS instead, either they should provide + * the codec string or expect the codec string to not have the profile + * level information. Though gst-play and ffplay still work with such + * a multi-variant playlist where CODECS does not contain profile info + * for video. + */ + + if let Some(codecs_str) = &variant.codecs { + return Ok(Some(codecs_str.clone())); + } + + let mut codecs_str: Vec = vec![]; + + if let Some(audio_group_id) = &variant.audio { + if let Some(caps) = codecs.get(audio_group_id.as_str()) { + for cap in caps.iter() { + match gst_pbutils::codec_utils_caps_get_mime_codec(cap) { + Ok(codec_str) => codecs_str.push(codec_str.to_string()), + Err(e) => return Err(e), + } + } + } + } + + if let Some(video_group_id) = &variant.video { + if let Some(caps) = codecs.get(video_group_id.as_str()) { + for cap in caps.iter() { + match gst_pbutils::codec_utils_caps_get_mime_codec(cap) { + Ok(codec_str) => codecs_str.push(codec_str.to_string()), + Err(e) => return Err(e), + } + } + } + } + + if let Some(caps) = codecs.get(variant.uri.as_str()) { + for cap in caps.iter() { + match gst_pbutils::codec_utils_caps_get_mime_codec(cap) { + Ok(codec_str) => codecs_str.push(codec_str.to_string()), + Err(e) => return Err(e), + } + } + } + + match codecs_str.is_empty() { + false => { + codecs_str.sort(); + codecs_str.dedup(); + Ok(Some(codecs_str.join(","))) + } + true => Ok(None), + } +} + +fn get_existing_hlssink_for_variant( + elem: &HlsSink4, + uri: String, + muxer_type: HlsSink4MuxerType, +) -> (bool, String, gst::Element) { + let sink_name = hlssink_name(uri, muxer_type); + + match muxer_type { + HlsSink4MuxerType::Cmaf => { + let hlssink = hlssink_element(muxer_type, sink_name.clone()); + (false, sink_name, hlssink) + } + HlsSink4MuxerType::MpegTs => { + if let Some(hlssink) = elem.obj().by_name(&sink_name) { + (true, sink_name, hlssink) + } else { + let hlssink = hlssink_element(muxer_type, sink_name.clone()); + (false, sink_name, hlssink) + } + } + } +} + +fn hlssink_element(muxer_type: HlsSink4MuxerType, sink_name: String) -> gst::Element { + match muxer_type { + HlsSink4MuxerType::Cmaf => gst::ElementFactory::make("hlscmafsink") + .name(sink_name) + .build() + .expect("hlscmafsink must be available"), + HlsSink4MuxerType::MpegTs => gst::ElementFactory::make("hlssink3") + .name(sink_name) + .build() + .expect("hlssink3 must be available"), + } +} + +fn hlssink_name(uri: String, muxer_type: HlsSink4MuxerType) -> String { + match muxer_type { + HlsSink4MuxerType::Cmaf => format!("hlscmafsink-{}", uri).to_string(), + HlsSink4MuxerType::MpegTs => format!("hlssink3-{}", uri).to_string(), + } +} + +fn hlssink_pad(hlssink: &gst::Element, muxer_type: HlsSink4MuxerType, is_video: bool) -> gst::Pad { + match muxer_type { + HlsSink4MuxerType::Cmaf => hlssink + .static_pad("sink") + .expect("hlscmafsink always has a sink pad"), + HlsSink4MuxerType::MpegTs => match is_video { + true => hlssink + .request_pad_simple("video") + .expect("hlssink3 always has a video pad"), + false => hlssink + .request_pad_simple("audio") + .expect("hlssink3 always has a video pad"), + }, + } +} + +fn hlssink_setup_paths( + pad: &HlsSink4Pad, + hlssink: &gst::Element, + muxer_type: HlsSink4MuxerType, + master_playlist_location: String, + uri: String, +) { + let master_playlist_parts: Vec<&str> = master_playlist_location.split('/').collect(); + let segment_location = if master_playlist_parts.is_empty() { + uri + } else { + let master_playlist_root = + &master_playlist_parts[..master_playlist_parts.len() - 1].join("/"); + format!("{master_playlist_root}/{uri}") + }; + + let parts: Vec<&str> = segment_location.split('/').collect(); + if parts.is_empty() { + gst::error!(CAT, imp: pad, "URI must be relative to master"); + gst::element_error!( + pad.parent(), + gst::ResourceError::Failed, + ["URI must be relative to master"] + ); + return; + } + + let segment_playlist_root = &parts[..parts.len() - 1].join("/"); + + hlssink.set_property("playlist-location", segment_location); + + match muxer_type { + HlsSink4MuxerType::Cmaf => { + hlssink.set_property( + "init-location", + format!("{segment_playlist_root}/{DEFAULT_INIT_LOCATION}"), + ); + hlssink.set_property( + "location", + format!("{segment_playlist_root}/{DEFAULT_CMAF_LOCATION}"), + ); + } + HlsSink4MuxerType::MpegTs => { + hlssink.set_property( + "location", + format!("{segment_playlist_root}/{DEFAULT_TS_LOCATION}"), + ); + } + } +} + +/* + * The EXT-X-MEDIA tag is used to relate Media Playlists that contain + * alternative Renditions. An EXT-X-MEDIA tag must have TYPE of media. + * We use the existence of the field to decide whether the user meant + * a requested media to be an alternate rendition or a variant stream + * by setting the corresponding property. + */ +fn is_alternate_rendition(s: &gst::Structure) -> bool { + match s.get::<&str>("media_type") { + Ok(s) => s == "AUDIO" || s == "VIDEO" || s == "audio" || s == "video", + Err(_) => false, + } +} +/* Helper functions end */ + +/* + * A pad/media requested represents either an alternate rendition or + * a variant stream. + */ +#[derive(Clone)] +enum HlsSink4PadSettings { + PadAlternative(AlternateRendition), + PadVariant(Variant), +} + +impl Default for HlsSink4PadSettings { + fn default() -> Self { + HlsSink4PadSettings::PadVariant(Variant::default()) + } +} + +impl From for HlsSink4PadSettings { + fn from(s: gst::Structure) -> Self { + match is_alternate_rendition(&s) { + true => HlsSink4PadSettings::PadAlternative(AlternateRendition::from(s)), + false => HlsSink4PadSettings::PadVariant(Variant::from(s)), + } + } +} + +impl From for gst::Structure { + fn from(obj: HlsSink4PadSettings) -> Self { + match obj { + HlsSink4PadSettings::PadAlternative(a) => Into::::into(a), + HlsSink4PadSettings::PadVariant(v) => Into::::into(v), + } + } +} + +#[derive(Default)] +pub(crate) struct HlsSink4Pad { + settings: Mutex, +} + +#[glib::object_subclass] +impl ObjectSubclass for HlsSink4Pad { + const NAME: &'static str = "HlsSink4Pad"; + type Type = super::HlsSink4Pad; + type ParentType = gst::GhostPad; +} + +impl ObjectImpl for HlsSink4Pad { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecBoxed::builder::("alternate-rendition") + .nick("Rendition") + .blurb("Alternate Rendition") + .mutable_ready() + .build(), + glib::ParamSpecBoxed::builder::("variant") + .nick("Variant") + .blurb("Variant Stream") + .mutable_ready() + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "alternate-rendition" => { + let s = value + .get::() + .expect("Must be a valid AlternateRendition"); + let rendition = AlternateRendition::from(s); + let alternative_media = AlternativeMedia::from(&rendition); + + let parent = self.parent(); + let elem = parent.imp(); + let elem_settings = elem.settings.lock().unwrap(); + let muxer_type = elem_settings.muxer_type; + + let mut state = elem.state.lock().unwrap(); + + let obj = self.obj(); + let pad_name = obj.name(); + + let is_video = pad_name.contains("video"); + let sink_name = hlssink_name(rendition.uri.clone(), muxer_type); + + let hlssink = hlssink_element(muxer_type, sink_name.clone()); + let peer_pad = hlssink_pad(&hlssink, muxer_type, is_video); + + elem.setup_hlssink(&hlssink, &elem_settings); + + hlssink_setup_paths( + self, + &hlssink, + muxer_type, + elem_settings.master_playlist_location.clone(), + rendition.uri.clone(), + ); + + parent + .add(&hlssink) + .expect("Failed to add hlssink for rendition"); + + self.obj() + .set_target(Some(&peer_pad)) + .expect("Failed to set target for rendition"); + + self.obj() + .set_active(true) + .expect("Failed to activate rendition pad"); + + state.pads.insert(pad_name.to_string(), sink_name); + state.alternatives.push(alternative_media); + + drop(elem_settings); + drop(state); + + let mut settings = self.settings.lock().unwrap(); + *settings = HlsSink4PadSettings::PadAlternative(rendition); + } + "variant" => { + let s = value + .get::() + .expect("Must be a valid Variant"); + let variant = Variant::from(s); + + let parent = self.parent(); + let elem = parent.imp(); + let elem_settings = elem.settings.lock().unwrap(); + let muxer_type = elem_settings.muxer_type; + + let mut state = elem.state.lock().unwrap(); + + let obj = self.obj(); + let pad_name = obj.name(); + + let is_video = pad_name.contains("video"); + + /* + * If the variant is to have muxed audio and video, look for + * a hlssink with the same URI. + */ + let (muxed, sink_name, hlssink) = get_existing_hlssink_for_variant( + elem, + variant.uri.clone(), + elem_settings.muxer_type, + ); + let peer_pad = hlssink_pad(&hlssink, muxer_type, is_video); + + if !muxed { + elem.setup_hlssink(&hlssink, &elem_settings); + + hlssink_setup_paths( + self, + &hlssink, + muxer_type, + elem_settings.master_playlist_location.clone(), + variant.uri.clone(), + ); + + parent + .add(&hlssink) + .expect("Failed to add hlssink for variant"); + + state.variants.push(variant.clone()); + } + + if muxer_type == HlsSink4MuxerType::MpegTs && is_video && variant.is_i_frame { + hlssink.set_property("i-frames-only", true); + } + + self.obj() + .set_target(Some(&peer_pad)) + .expect("Failed to set target for variant"); + + self.obj() + .set_active(true) + .expect("Failed to activate variant pad"); + + state.pads.insert(pad_name.to_string(), sink_name); + + drop(elem_settings); + drop(state); + + let mut settings = self.settings.lock().unwrap(); + *settings = HlsSink4PadSettings::PadVariant(variant); + } + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let settings = self.settings.lock().unwrap(); + + match pspec.name() { + "alternate-rendition" | "variant" => { + Into::::into(settings.clone()).to_value() + } + _ => unimplemented!(), + } + } +} + +impl GstObjectImpl for HlsSink4Pad {} + +impl PadImpl for HlsSink4Pad {} + +impl ProxyPadImpl for HlsSink4Pad {} + +impl GhostPadImpl for HlsSink4Pad {} + +impl HlsSink4Pad { + fn parent(&self) -> super::HlsSink4 { + self.obj() + .parent() + .map(|elem_obj| { + elem_obj + .downcast::() + .expect("Wrong Element type") + }) + .expect("Pad should have a parent at this stage") + } +} + +#[derive(Default)] +struct State { + all_mimes: u32, + audio_pad_serial: u32, + video_pad_serial: u32, + pads: HashMap, + alternatives: Vec, + variants: Vec, + codecs: HashMap>, + wrote_manifest: bool, +} + +#[derive(Debug)] +struct Settings { + master_playlist_location: String, + muxer_type: HlsSink4MuxerType, + /* Below settings will be applied to all underlying hlscmafsink/hlssink3 */ + playlist_length: u32, + playlist_type: Option, + max_num_segment_files: usize, + send_keyframe_requests: bool, + target_duration: u32, +} + +impl Default for Settings { + fn default() -> Self { + Self { + master_playlist_location: DEFAULT_MASTER_PLAYLIST_LOCATION.to_string(), + playlist_length: DEFAULT_PLAYLIST_LENGTH, + playlist_type: Some(DEFAULT_PLAYLIST_TYPE), + max_num_segment_files: DEFAULT_MAX_NUM_SEGMENT_FILES as usize, + send_keyframe_requests: DEFAULT_SEND_KEYFRAME_REQUESTS, + target_duration: DEFAULT_TARGET_DURATION, + muxer_type: DEFAULT_MUXER_TYPE, + } + } +} + +#[derive(Default)] +pub struct HlsSink4 { + settings: Mutex, + state: Mutex, +} + +#[glib::object_subclass] +impl ObjectSubclass for HlsSink4 { + const NAME: &'static str = "GstHlsSink4"; + type Type = super::HlsSink4; + type ParentType = gst::Bin; +} + +impl ObjectImpl for HlsSink4 { + fn constructed(&self) { + self.parent_constructed(); + } + + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecString::builder("master-playlist-location") + .nick("Master Playlist file location") + .blurb("Location of the master playlist file to write") + .default_value(Some(DEFAULT_MASTER_PLAYLIST_LOCATION)) + .build(), + glib::ParamSpecUInt::builder("max-files") + .nick("Max files") + .blurb("Maximum number of files to keep on disk. Once the maximum is reached, old files start to be deleted to make room for new ones.") + .build(), + glib::ParamSpecEnum::builder_with_default("muxer-type", DEFAULT_MUXER_TYPE) + .nick("Muxer Type") + .blurb("The muxer to use, cmafmux or mpegtsmux, accordingly selects hlssink3 or hlscmafsink") + .build(), + glib::ParamSpecUInt::builder("playlist-length") + .nick("Playlist length") + .blurb("Length of HLS playlist. To allow players to conform to section 6.3.3 of the HLS specification, this should be at least 3. If set to 0, the playlist will be infinite.") + .default_value(DEFAULT_PLAYLIST_LENGTH) + .build(), + glib::ParamSpecEnum::builder_with_default("playlist-type", DEFAULT_PLAYLIST_TYPE) + .nick("Playlist Type") + .blurb("The type of the playlist to use. When VOD type is set, the playlist will be live until the pipeline ends execution.") + .build(), + glib::ParamSpecBoolean::builder("send-keyframe-requests") + .nick("Send Keyframe Requests") + .blurb("Send keyframe requests to ensure correct fragmentation. If this is disabled then the input must have keyframes in regular intervals.") + .default_value(DEFAULT_SEND_KEYFRAME_REQUESTS) + .build(), + glib::ParamSpecUInt::builder("target-duration") + .nick("Target duration") + .blurb("The target duration in seconds of a segment/file. (0 - disabled, useful for management of segment duration by the streaming server)") + .default_value(DEFAULT_TARGET_DURATION) + .build(), + ] + }); + + &PROPERTIES + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let mut settings = self.settings.lock().expect("Failed to get settings lock"); + + gst::debug!( + CAT, + imp: self, + "Setting property '{}' to '{:?}'", + pspec.name(), + value + ); + + match pspec.name() { + "master-playlist-location" => { + settings.master_playlist_location = + value.get::().expect("type checked upstream"); + } + "max-files" => { + let max_files: u32 = value.get().expect("type checked upstream"); + settings.max_num_segment_files = max_files as usize; + } + "muxer-type" => { + settings.muxer_type = value + .get::() + .expect("type checked upstream"); + } + "playlist-length" => { + settings.playlist_length = value.get().expect("type checked upstream"); + } + "playlist-type" => { + settings.playlist_type = value + .get::() + .expect("type checked upstream") + .into(); + } + "target-duration" => { + settings.target_duration = value.get().expect("type checked upstream"); + } + + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let settings = self.settings.lock().expect("Failed to get settings lock"); + + match pspec.name() { + "master-playlist-location" => settings.master_playlist_location.to_value(), + "max-files" => { + let max_files = settings.max_num_segment_files as u32; + max_files.to_value() + } + "muxer-type" => settings.muxer_type.to_value(), + "playlist-length" => settings.playlist_length.to_value(), + "playlist-type" => settings + .playlist_type + .unwrap_or(DEFAULT_PLAYLIST_TYPE) + .to_value(), + "send-keyframe-requests" => settings.send_keyframe_requests.to_value(), + "target-duration" => settings.target_duration.to_value(), + _ => unimplemented!(), + } + } + + fn signals() -> &'static [glib::subclass::Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![ + glib::subclass::Signal::builder(SIGNAL_GET_MASTER_PLAYLIST_STREAM) + .param_types([String::static_type()]) + .return_type::>() + .class_handler(|_, args| { + let master_playlist_location = args[1].get::().expect("signal arg"); + let elem = args[0].get::().expect("signal arg"); + let imp = elem.imp(); + + Some( + imp.new_file_stream(&master_playlist_location) + .ok() + .to_value(), + ) + }) + .accumulator(|_hint, ret, value| { + /* First signal handler wins */ + *ret = value.clone(); + false + }) + .build(), + /* We will proxy the below signals from the underlying hlssink3/hlscmafsink */ + glib::subclass::Signal::builder(SIGNAL_DELETE_FRAGMENT) + .param_types([String::static_type()]) + .return_type::() + .class_handler(|_, args| { + let fragment_location = args[1].get::().expect("signal arg"); + let elem = args[0].get::().expect("signal arg"); + let imp = elem.imp(); + + imp.delete_fragment(&fragment_location); + Some(true.to_value()) + }) + .accumulator(|_hint, ret, value| { + // First signal handler wins + *ret = value.clone(); + false + }) + .build(), + glib::subclass::Signal::builder(SIGNAL_GET_FRAGMENT_STREAM) + .param_types([String::static_type()]) + .return_type::>() + .class_handler(|_, args| { + let fragment_location = args[1].get::().expect("signal arg"); + let elem = args[0].get::().expect("signal arg"); + let imp = elem.imp(); + + Some(imp.new_file_stream(&fragment_location).ok().to_value()) + }) + .accumulator(|_hint, ret, value| { + // First signal handler wins + *ret = value.clone(); + false + }) + .build(), + glib::subclass::Signal::builder(SIGNAL_GET_INIT_STREAM) + .param_types([String::static_type()]) + .return_type::>() + .class_handler(|_, args| { + let elem = args[0].get::().expect("signal arg"); + let init_location = args[1].get::().expect("signal arg"); + let imp = elem.imp(); + + Some(imp.new_file_stream(&init_location).ok().to_value()) + }) + .accumulator(|_hint, ret, value| { + // First signal handler wins + *ret = value.clone(); + false + }) + .build(), + glib::subclass::Signal::builder(SIGNAL_GET_PLAYLIST_STREAM) + .param_types([String::static_type()]) + .return_type::>() + .class_handler(|_, args| { + let playlist_location = args[1].get::().expect("signal arg"); + let elem = args[0].get::().expect("signal arg"); + let imp = elem.imp(); + + Some(imp.new_file_stream(&playlist_location).ok().to_value()) + }) + .accumulator(|_hint, ret, value| { + // First signal handler wins + *ret = value.clone(); + false + }) + .build(), + ] + }); + + SIGNALS.as_ref() + } +} + +impl GstObjectImpl for HlsSink4 {} + +impl ElementImpl for HlsSink4 { + fn change_state( + &self, + transition: gst::StateChange, + ) -> Result { + let state = self.state.lock().unwrap(); + + if !self.validate_alternate_rendition_and_variants(&state.alternatives, &state.variants) { + gst::element_error!( + self.obj(), + gst::ResourceError::Settings, + ["Validation of alternate rendition and variants failed"] + ); + return Err(gst::StateChangeError); + } + + drop(state); + + self.parent_change_state(transition) + } + + fn metadata() -> Option<&'static gst::subclass::ElementMetadata> { + static ELEMENT_METADATA: Lazy = Lazy::new(|| { + gst::subclass::ElementMetadata::new( + "HTTP Live Streaming sink", + "Sink/Muxer", + "HTTP Live Streaming sink", + "Sanchayan Maity ", + ) + }); + + Some(&*ELEMENT_METADATA) + } + + fn pad_templates() -> &'static [gst::PadTemplate] { + static PAD_TEMPLATES: Lazy> = Lazy::new(|| { + let caps_any = gst::Caps::new_any(); + + let audio_pad_template = gst::PadTemplate::with_gtype( + "audio_%u", + gst::PadDirection::Sink, + gst::PadPresence::Request, + &caps_any, + super::HlsSink4Pad::static_type(), + ) + .unwrap(); + + let video_pad_template = gst::PadTemplate::with_gtype( + "video_%u", + gst::PadDirection::Sink, + gst::PadPresence::Request, + &caps_any, + super::HlsSink4Pad::static_type(), + ) + .unwrap(); + + vec![audio_pad_template, video_pad_template] + }); + + PAD_TEMPLATES.as_ref() + } + + fn request_new_pad( + &self, + templ: &gst::PadTemplate, + _name: Option<&str>, + _caps: Option<&gst::Caps>, + ) -> Option { + let mut state = self.state.lock().unwrap(); + + match templ.name_template() { + "audio_%u" => { + let audio_pad_name = format!("audio_{}", state.audio_pad_serial); + let sink_pad = gst::PadBuilder::::from_template(templ) + .name(audio_pad_name.clone()) + .event_function(|pad, parent, event| { + HlsSink4::catch_panic_pad_function( + parent, + || false, + |this| this.sink_event(pad, event), + ) + }) + .flags(gst::PadFlags::FIXED_CAPS) + .build(); + + state.audio_pad_serial += 1; + + self.obj() + .add_pad(&sink_pad) + .expect("Failed to add audio pad"); + + Some(sink_pad.upcast()) + } + "video_%u" => { + let video_pad_name = format!("video_{}", state.video_pad_serial); + let sink_pad = gst::PadBuilder::::from_template(templ) + .name(video_pad_name.clone()) + .event_function(|pad, parent, event| { + HlsSink4::catch_panic_pad_function( + parent, + || false, + |this| this.sink_event(pad, event), + ) + }) + .flags(gst::PadFlags::FIXED_CAPS) + .build(); + + state.video_pad_serial += 1; + + self.obj() + .add_pad(&sink_pad) + .expect("Failed to add video pad"); + + Some(sink_pad.upcast()) + } + other_name => { + gst::warning!( + CAT, + imp: self, + "requested_new_pad: name \"{}\" is not one of audio, video", + other_name + ); + None + } + } + } + + fn release_pad(&self, pad: &gst::Pad) { + pad.set_active(false).unwrap(); + + let ghost_pad = pad.downcast_ref::().unwrap(); + let pad_name = ghost_pad.name().to_string(); + + let mut state = self.state.lock().unwrap(); + if let Some(hlssink_name) = state.pads.get(&pad_name.to_string()) { + if let Some(hlssink) = self.obj().by_name(hlssink_name) { + if let Err(err) = self.obj().remove(&hlssink) { + gst::error!(CAT, imp: self, "Failed to remove hlssink for pad: {} with error: {}", pad_name, err); + } + state.pads.remove(&pad_name); + } + } + + self.obj().remove_pad(pad).expect("Failed to remove pad"); + } +} + +impl BinImpl for HlsSink4 { + fn handle_message(&self, message: gst::Message) { + use gst::MessageView; + match message.view() { + MessageView::Eos(eos) => { + gst::debug!(CAT, imp: self, "Got EOS from {:?}", eos.src()); + self.parent_handle_message(message) + } + MessageView::Error(err) => { + gst::error!(CAT, imp: self, "Got error: {} {:?}", err.error(), err.debug()); + self.parent_handle_message(message) + } + _ => self.parent_handle_message(message), + } + } +} + +impl ChildProxyImpl for HlsSink4 { + fn children_count(&self) -> u32 { + let object = self.obj(); + object.num_pads() as u32 + } + + fn child_by_name(&self, name: &str) -> Option { + let object = self.obj(); + object + .pads() + .into_iter() + .find(|p| p.name() == name) + .map(|p| p.upcast()) + } + + fn child_by_index(&self, index: u32) -> Option { + let object = self.obj(); + object + .pads() + .into_iter() + .nth(index as usize) + .map(|p| p.upcast()) + } +} + +impl HlsSink4 { + fn sink_event(&self, hlspad: &super::HlsSink4Pad, event: gst::Event) -> bool { + let pad = hlspad.upcast_ref::(); + + gst::log!(CAT, obj: pad, "Handling event {event:?}"); + + if let gst::EventView::Caps(ev) = event.view() { + let pad_settings = hlspad.imp().settings.lock().unwrap().to_owned(); + let mut state = self.state.lock().unwrap(); + let wrote_manifest = state.wrote_manifest; + + let caps = ev.caps(); + state.all_mimes += 1; + + /* + * Keep track of caps for every pad. Depending on whether a + * requested pad/media is an alternate rendition or variant + * stream, track the caps as per group id. + */ + match pad_settings { + HlsSink4PadSettings::PadAlternative(ref a) => { + accumulate_codec_caps(&mut state.codecs, caps.to_owned(), a.group_id.clone()); + } + HlsSink4PadSettings::PadVariant(ref v) => { + if let Some(group_id) = &v.video { + accumulate_codec_caps(&mut state.codecs, caps.to_owned(), group_id.clone()); + } else if let Some(group_id) = &v.audio { + accumulate_codec_caps(&mut state.codecs, caps.to_owned(), group_id.clone()); + } else { + /* + * Variant streams which do not have AUDIO or VIDEO + * set and thus are not associated with any rendition + * groups, are tracked via their URI. + */ + accumulate_codec_caps(&mut state.codecs, caps.to_owned(), v.uri.clone()); + } + } + } + + /* + * Write the master playlist only if we have got caps on all the + * sink pads. + */ + let write_manifest = state.all_mimes == self.obj().num_sink_pads() as u32; + + drop(state); + drop(pad_settings); + + if !wrote_manifest && write_manifest { + let mut state = self.state.lock().unwrap(); + let codecs = state.codecs.clone(); + + for variant in state.variants.iter_mut() { + match build_codec_string_for_variant(variant, &codecs) { + Ok(codec_str) => variant.codecs = codec_str, + Err(e) => { + gst::error!(CAT, imp: self, "Failed to build codec string with error: {}", e); + gst::element_error!( + self.obj(), + gst::ResourceError::Failed, + ["Failed to build codec string with error: {}", e] + ); + } + } + } + + drop(state); + + self.write_master_playlist(); + } + + gst::debug!(CAT, imp: self, "Received caps {:?} on pad: {}", caps, pad.name()); + } + + gst::Pad::event_default(pad, Some(&*self.obj()), event) + } + + fn setup_hlssink(&self, hlssink: &gst::Element, settings: &Settings) { + /* Propagate some settings to the underlying hlscmafsink/hlssink3 */ + hlssink.set_property("max-files", settings.max_num_segment_files as u32); + hlssink.set_property("playlist-length", settings.playlist_length); + hlssink.set_property_from_str( + "playlist-type", + &settings + .playlist_type + .unwrap_or(DEFAULT_PLAYLIST_TYPE) + .to_string(), + ); + if settings.muxer_type == HlsSink4MuxerType::MpegTs { + hlssink.set_property("send-keyframe-requests", settings.send_keyframe_requests); + } + hlssink.set_property("target-duration", settings.target_duration); + + let mut signals = vec![ + SIGNAL_DELETE_FRAGMENT, + SIGNAL_GET_FRAGMENT_STREAM, + SIGNAL_GET_PLAYLIST_STREAM, + ]; + + if settings.muxer_type == HlsSink4MuxerType::Cmaf { + signals.push(SIGNAL_GET_INIT_STREAM); + } + + for signal in signals { + hlssink.connect(signal, false, { + let self_weak = self.downgrade(); + move |args| -> Option { + let self_ = self_weak.upgrade()?; + let location = args[1].get::<&str>().unwrap(); + + if signal == SIGNAL_DELETE_FRAGMENT { + Some( + self_ + .obj() + .emit_by_name::(signal, &[&location]) + .to_value(), + ) + } else { + Some( + self_ + .obj() + .emit_by_name::>(signal, &[&location]) + .to_value(), + ) + } + } + }); + } + } + + fn validate_alternate_rendition_and_variants( + &self, + alternatives: &[AlternativeMedia], + variants: &[Variant], + ) -> bool { + if variants.is_empty() { + gst::error!(CAT, imp: self, "Empty variant stream"); + return false; + } + + let variants_audio_group_ids = variants + .iter() + .filter_map(|variant| variant.audio.clone()) + .collect::>(); + let variants_video_group_ids = variants + .iter() + .filter_map(|variant| variant.video.clone()) + .collect::>(); + + for alternate in alternatives.iter() { + let groupid = &alternate.group_id; + + let res = if alternate.media_type == AlternativeMediaType::Audio { + variants_audio_group_ids + .clone() + .into_iter() + .find(|x| *x == *groupid) + } else { + variants_video_group_ids + .clone() + .into_iter() + .find(|x| *x == *groupid) + }; + + if res.is_none() { + gst::error!(CAT, imp: self, "No matching GROUP-ID for alternate rendition in variant stream"); + return false; + } + } + + // NAME in alternate renditions must be unique + let mut names = alternatives + .iter() + .map(|alt| Some(alt.name.clone())) + .collect::>(); + let names_len = names.len(); + names.dedup(); + if names.len() < names_len { + gst::error!(CAT, imp: self, "Duplicate NAME not allowed in alternate rendition"); + return false; + } + + true + } + + fn write_master_playlist(&self) { + let mut state = self.state.lock().unwrap(); + let variant_streams = state.variants.iter().map(VariantStream::from).collect(); + let alternatives = state.alternatives.clone(); + state.wrote_manifest = true; + drop(state); + + let settings = self.settings.lock().unwrap(); + let master_playlist_location = settings.master_playlist_location.clone(); + let master_playlist_filename = path::Path::new(&master_playlist_location) + .to_str() + .expect("Master playlist path to string conversion failed"); + drop(settings); + + let playlist = MasterPlaylist { + version: Some(4), + variants: variant_streams, + alternatives, + ..Default::default() + }; + + match self.obj().emit_by_name::>( + SIGNAL_GET_MASTER_PLAYLIST_STREAM, + &[&master_playlist_filename], + ) { + Some(s) => { + let mut stream = s.into_write(); + + if let Err(err) = playlist.write_to(&mut stream) { + gst::error!(CAT, imp: self, "Failed to write master playlist with error: {}", err); + gst::element_error!( + self.obj(), + gst::ResourceError::Settings, + ["Failed to write master playlist with error: {}", err] + ); + } + } + None => { + gst::error!(CAT, imp: self, "Could not get stream to write master playlist"); + gst::element_error!( + self.obj(), + gst::ResourceError::Settings, + ["Could not get stream to write master playlist"] + ); + } + } + } + + fn new_file_stream

(&self, location: &P) -> Result + where + P: AsRef, + { + let file = File::create(location).map_err(move |err| { + let error_msg = gst::error_msg!( + gst::ResourceError::OpenWrite, + [ + "Could not open file {} for writing: {}", + location.as_ref().to_str().unwrap(), + err.to_string(), + ] + ); + + self.post_error_message(error_msg); + + err.to_string() + })?; + + Ok(gio::WriteOutputStream::new(file).upcast()) + } + + fn delete_fragment

(&self, location: &P) + where + P: AsRef, + { + let _ = fs::remove_file(location).map_err(|err| { + gst::warning!( + CAT, + imp: self, + "Could not delete segment file: {}", + err.to_string() + ); + }); + } +} diff --git a/net/hlssink3/src/hlssink4/mod.rs b/net/hlssink3/src/hlssink4/mod.rs new file mode 100644 index 00000000..dca413a9 --- /dev/null +++ b/net/hlssink3/src/hlssink4/mod.rs @@ -0,0 +1,88 @@ +// Copyright (C) 2024, asymptotic.io +// Author: Sanchayan Maity +// +// This Source Code Form is subject to the terms of the Mozilla Public License, v2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at +// . +// +// SPDX-License-Identifier: MPL-2.0 + +use gst::glib; +use gst::prelude::*; + +mod imp; + +#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Clone, Copy, glib::Enum)] +#[repr(u32)] +#[enum_type(name = "GstHlsSink4MuxerType")] +#[non_exhaustive] +pub enum HlsSink4MuxerType { + #[default] + #[enum_value(name = "cmaf", nick = "cmaf")] + Cmaf = 0, + + #[enum_value(name = "mpegts", nick = "mpegts")] + MpegTs = 1, +} + +#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Clone, Copy, glib::Enum)] +#[repr(u32)] +#[enum_type(name = "GstHlsSink4AlternativeMediaType")] +#[non_exhaustive] +pub enum HlsSink4AlternativeMediaType { + #[default] + #[enum_value(name = "AUDIO", nick = "audio")] + Audio = 0, + + #[enum_value(name = "VIDEO", nick = "video")] + Video = 1, +} + +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Clone, Copy, glib::Enum)] +#[repr(u32)] +#[enum_type(name = "GstHlsSink4PlaylistType")] +#[non_exhaustive] +pub enum HlsSink4PlaylistType { + #[enum_value( + name = "Unspecified: The tag `#EXT-X-PLAYLIST-TYPE` won't be present in the playlist during the pipeline processing.", + nick = "unspecified" + )] + Unspecified = 0, + + #[enum_value( + name = "Event: No segments will be removed from the playlist. At the end of the processing, the tag `#EXT-X-ENDLIST` is added to the playlist. The tag `#EXT-X-PLAYLIST-TYPE:EVENT` will be present in the playlist.", + nick = "event" + )] + Event = 1, + + #[enum_value( + name = "Vod: The playlist behaves like the `event` option (a live event), but at the end of the processing, the playlist will be set to `#EXT-X-PLAYLIST-TYPE:VOD`.", + nick = "vod" + )] + Vod = 2, +} + +glib::wrapper! { + pub struct HlsSink4(ObjectSubclass) @extends gst::Bin, gst::Element, gst::Object; +} + +glib::wrapper! { + pub(crate) struct HlsSink4Pad(ObjectSubclass) @extends gst::GhostPad, gst::ProxyPad, gst::Pad, gst::Object; +} + +pub fn register(plugin: &gst::Plugin) -> Result<(), glib::BoolError> { + #[cfg(feature = "doc")] + { + HlsSink4MuxerType::static_type().mark_as_plugin_api(gst::PluginAPIFlags::empty()); + HlsSink4PlaylistType::static_type().mark_as_plugin_api(gst::PluginAPIFlags::empty()); + HlsSink4AlternativeMediaType::static_type() + .mark_as_plugin_api(gst::PluginAPIFlags::empty()); + } + + gst::Element::register( + Some(plugin), + "hlssink4", + gst::Rank::NONE, + HlsSink4::static_type(), + ) +} diff --git a/net/hlssink3/src/imp.rs b/net/hlssink3/src/imp.rs deleted file mode 100644 index 84bc2dd0..00000000 --- a/net/hlssink3/src/imp.rs +++ /dev/null @@ -1,1600 +0,0 @@ -// Copyright (C) 2021 Rafael Caricio -// Copyright (C) 2023 Seungha Yang -// -// This Source Code Form is subject to the terms of the Mozilla Public License, v2.0. -// If a copy of the MPL was not distributed with this file, You can obtain one at -// . -// -// SPDX-License-Identifier: MPL-2.0 - -use crate::playlist::Playlist; -use crate::HlsSink3PlaylistType; -use chrono::{DateTime, Duration, Utc}; -use gio::prelude::*; -use gst::glib; -use gst::prelude::*; -use gst::subclass::prelude::*; -use m3u8_rs::{MediaPlaylist, MediaPlaylistType, MediaSegment}; -use once_cell::sync::Lazy; -use std::fs; -use std::io::Write; -use std::path; -use std::sync::Mutex; - -const DEFAULT_TS_LOCATION: &str = "segment%05d.ts"; -const DEFAULT_INIT_LOCATION: &str = "init%05d.mp4"; -const DEFAULT_CMAF_LOCATION: &str = "segment%05d.m4s"; -const DEFAULT_PLAYLIST_LOCATION: &str = "playlist.m3u8"; -const DEFAULT_MAX_NUM_SEGMENT_FILES: u32 = 10; -const DEFAULT_TARGET_DURATION: u32 = 15; -const DEFAULT_PLAYLIST_LENGTH: u32 = 5; -const DEFAULT_PLAYLIST_TYPE: HlsSink3PlaylistType = HlsSink3PlaylistType::Unspecified; -const DEFAULT_I_FRAMES_ONLY_PLAYLIST: bool = false; -const DEFAULT_PROGRAM_DATE_TIME_TAG: bool = false; -const DEFAULT_CLOCK_TRACKING_FOR_PDT: bool = true; -const DEFAULT_SEND_KEYFRAME_REQUESTS: bool = true; -const DEFAULT_SYNC: bool = true; -const DEFAULT_LATENCY: gst::ClockTime = - gst::ClockTime::from_mseconds((DEFAULT_TARGET_DURATION * 500) as u64); -const DEFAULT_ENDLIST: bool = true; - -const SIGNAL_GET_PLAYLIST_STREAM: &str = "get-playlist-stream"; -const SIGNAL_GET_INIT_STREAM: &str = "get-init-stream"; -const SIGNAL_GET_FRAGMENT_STREAM: &str = "get-fragment-stream"; -const SIGNAL_DELETE_FRAGMENT: &str = "delete-fragment"; - -static CAT: Lazy = Lazy::new(|| { - gst::DebugCategory::new("hlssink3", gst::DebugColorFlags::empty(), Some("HLS sink")) -}); - -macro_rules! base_imp { - ($i:expr) => { - $i.obj().upcast_ref::().imp() - }; -} - -impl From for Option { - fn from(pl_type: HlsSink3PlaylistType) -> Self { - use HlsSink3PlaylistType::*; - match pl_type { - Unspecified => None, - Event => Some(MediaPlaylistType::Event), - Vod => Some(MediaPlaylistType::Vod), - } - } -} - -impl From> for HlsSink3PlaylistType { - fn from(inner_pl_type: Option<&MediaPlaylistType>) -> Self { - use HlsSink3PlaylistType::*; - match inner_pl_type { - None | Some(MediaPlaylistType::Other(_)) => Unspecified, - Some(MediaPlaylistType::Event) => Event, - Some(MediaPlaylistType::Vod) => Vod, - } - } -} - -struct Settings { - playlist_location: String, - playlist_root: Option, - playlist_length: u32, - max_num_segment_files: usize, - enable_program_date_time: bool, - pdt_follows_pipeline_clock: bool, - enable_endlist: bool, -} - -impl Default for Settings { - fn default() -> Self { - Self { - playlist_location: String::from(DEFAULT_PLAYLIST_LOCATION), - playlist_root: None, - playlist_length: DEFAULT_PLAYLIST_LENGTH, - max_num_segment_files: DEFAULT_MAX_NUM_SEGMENT_FILES as usize, - enable_program_date_time: DEFAULT_PROGRAM_DATE_TIME_TAG, - pdt_follows_pipeline_clock: DEFAULT_CLOCK_TRACKING_FOR_PDT, - enable_endlist: DEFAULT_ENDLIST, - } - } -} - -struct PlaylistContext { - pdt_base_utc: Option>, - pdt_base_running_time: Option, - playlist: Playlist, - old_segment_locations: Vec, - segment_template: String, - playlist_location: String, - max_num_segment_files: usize, - playlist_length: u32, -} - -#[derive(Default)] -struct State { - context: Option, -} - -#[derive(Default)] -pub struct HlsBaseSink { - settings: Mutex, - state: Mutex, -} - -#[glib::object_subclass] -impl ObjectSubclass for HlsBaseSink { - const NAME: &'static str = "GstHlsBaseSink"; - type Type = super::HlsBaseSink; - type ParentType = gst::Bin; -} - -trait HlsBaseSinkImpl: BinImpl {} - -unsafe impl IsSubclassable for super::HlsBaseSink {} - -impl ObjectImpl for HlsBaseSink { - fn constructed(&self) { - self.parent_constructed(); - - let obj = self.obj(); - obj.set_suppressed_flags(gst::ElementFlags::SINK | gst::ElementFlags::SOURCE); - obj.set_element_flags(gst::ElementFlags::SINK); - } - - fn properties() -> &'static [glib::ParamSpec] { - static PROPERTIES: Lazy> = Lazy::new(|| { - vec![ - glib::ParamSpecString::builder("playlist-location") - .nick("Playlist Location") - .blurb("Location of the playlist to write.") - .default_value(Some(DEFAULT_PLAYLIST_LOCATION)) - .build(), - glib::ParamSpecString::builder("playlist-root") - .nick("Playlist Root") - .blurb("Base path for the segments in the playlist file.") - .build(), - glib::ParamSpecUInt::builder("max-files") - .nick("Max files") - .blurb("Maximum number of files to keep on disk. Once the maximum is reached, old files start to be deleted to make room for new ones.") - .build(), - glib::ParamSpecUInt::builder("playlist-length") - .nick("Playlist length") - .blurb("Length of HLS playlist. To allow players to conform to section 6.3.3 of the HLS specification, this should be at least 3. If set to 0, the playlist will be infinite.") - .default_value(DEFAULT_PLAYLIST_LENGTH) - .build(), - glib::ParamSpecBoolean::builder("enable-program-date-time") - .nick("add EXT-X-PROGRAM-DATE-TIME tag") - .blurb("put EXT-X-PROGRAM-DATE-TIME tag in the playlist") - .default_value(DEFAULT_PROGRAM_DATE_TIME_TAG) - .build(), - glib::ParamSpecBoolean::builder("pdt-follows-pipeline-clock") - .nick("Whether Program-Date-Time should follow the pipeline clock") - .blurb("As there might be drift between the wallclock and pipeline clock, this controls whether the Program-Date-Time markers should follow the pipeline clock rate (true), or be skewed to match the wallclock rate (false).") - .default_value(DEFAULT_CLOCK_TRACKING_FOR_PDT) - .build(), - glib::ParamSpecBoolean::builder("enable-endlist") - .nick("Enable Endlist") - .blurb("Write \"EXT-X-ENDLIST\" tag to manifest at the end of stream") - .default_value(DEFAULT_ENDLIST) - .build(), - ] - }); - - PROPERTIES.as_ref() - } - - fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { - let mut settings = self.settings.lock().unwrap(); - match pspec.name() { - "playlist-location" => { - settings.playlist_location = value - .get::>() - .expect("type checked upstream") - .unwrap_or_else(|| String::from(DEFAULT_PLAYLIST_LOCATION)); - } - "playlist-root" => { - settings.playlist_root = value - .get::>() - .expect("type checked upstream"); - } - "max-files" => { - let max_files: u32 = value.get().expect("type checked upstream"); - settings.max_num_segment_files = max_files as usize; - } - "playlist-length" => { - settings.playlist_length = value.get().expect("type checked upstream"); - } - "enable-program-date-time" => { - settings.enable_program_date_time = value.get().expect("type checked upstream"); - } - "pdt-follows-pipeline-clock" => { - settings.pdt_follows_pipeline_clock = value.get().expect("type checked upstream"); - } - "enable-endlist" => { - settings.enable_endlist = value.get().expect("type checked upstream"); - } - _ => unimplemented!(), - }; - } - - fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { - let settings = self.settings.lock().unwrap(); - match pspec.name() { - "playlist-location" => settings.playlist_location.to_value(), - "playlist-root" => settings.playlist_root.to_value(), - "max-files" => { - let max_files = settings.max_num_segment_files as u32; - max_files.to_value() - } - "playlist-length" => settings.playlist_length.to_value(), - "enable-program-date-time" => settings.enable_program_date_time.to_value(), - "pdt-follows-pipeline-clock" => settings.pdt_follows_pipeline_clock.to_value(), - "enable-endlist" => settings.enable_endlist.to_value(), - _ => unimplemented!(), - } - } - - fn signals() -> &'static [glib::subclass::Signal] { - static SIGNALS: Lazy> = Lazy::new(|| { - vec![ - glib::subclass::Signal::builder(SIGNAL_GET_PLAYLIST_STREAM) - .param_types([String::static_type()]) - .return_type::>() - .class_handler(|_, args| { - let elem = args[0].get::().expect("signal arg"); - let playlist_location = args[1].get::().expect("signal arg"); - let imp = elem.imp(); - - Some(imp.new_file_stream(&playlist_location).ok().to_value()) - }) - .accumulator(|_hint, ret, value| { - // First signal handler wins - *ret = value.clone(); - false - }) - .build(), - glib::subclass::Signal::builder(SIGNAL_GET_FRAGMENT_STREAM) - .param_types([String::static_type()]) - .return_type::>() - .class_handler(|_, args| { - let elem = args[0].get::().expect("signal arg"); - let fragment_location = args[1].get::().expect("signal arg"); - let imp = elem.imp(); - - Some(imp.new_file_stream(&fragment_location).ok().to_value()) - }) - .accumulator(|_hint, ret, value| { - // First signal handler wins - *ret = value.clone(); - false - }) - .build(), - glib::subclass::Signal::builder(SIGNAL_DELETE_FRAGMENT) - .param_types([String::static_type()]) - .return_type::() - .class_handler(|_, args| { - let elem = args[0].get::().expect("signal arg"); - let fragment_location = args[1].get::().expect("signal arg"); - let imp = elem.imp(); - - imp.delete_fragment(&fragment_location); - Some(true.to_value()) - }) - .accumulator(|_hint, ret, value| { - // First signal handler wins - *ret = value.clone(); - false - }) - .build(), - ] - }); - - SIGNALS.as_ref() - } -} - -impl GstObjectImpl for HlsBaseSink {} - -impl ElementImpl for HlsBaseSink { - fn change_state( - &self, - transition: gst::StateChange, - ) -> Result { - let ret = self.parent_change_state(transition)?; - - match transition { - gst::StateChange::PlayingToPaused => { - let mut state = self.state.lock().unwrap(); - if let Some(context) = state.context.as_mut() { - // reset mapping from rt to utc. during pause - // rt is stopped but utc keep moving so need to - // calculate the mapping again - context.pdt_base_running_time = None; - context.pdt_base_utc = None - } - } - gst::StateChange::PausedToReady => { - self.close_playlist(); - } - _ => (), - } - - Ok(ret) - } -} - -impl BinImpl for HlsBaseSink {} - -impl HlsBaseSinkImpl for HlsBaseSink {} - -impl HlsBaseSink { - pub fn open_playlist(&self, playlist: Playlist, segment_template: String) { - let mut state = self.state.lock().unwrap(); - let settings = self.settings.lock().unwrap(); - state.context = Some(PlaylistContext { - pdt_base_utc: None, - pdt_base_running_time: None, - playlist, - old_segment_locations: Vec::new(), - segment_template, - playlist_location: settings.playlist_location.clone(), - max_num_segment_files: settings.max_num_segment_files, - playlist_length: settings.playlist_length, - }); - } - - fn close_playlist(&self) { - let mut state = self.state.lock().unwrap(); - if let Some(mut context) = state.context.take() { - if context.playlist.is_rendering() { - context - .playlist - .stop(self.settings.lock().unwrap().enable_endlist); - let _ = self.write_playlist(&mut context); - } - } - } - - pub fn get_fragment_stream(&self, fragment_id: u32) -> Option<(gio::OutputStream, String)> { - let mut state = self.state.lock().unwrap(); - let context = match state.context.as_mut() { - Some(context) => context, - None => { - gst::error!( - CAT, - imp: self, - "Playlist is not configured", - ); - - return None; - } - }; - - let location = match sprintf::sprintf!(&context.segment_template, fragment_id) { - Ok(file_name) => file_name, - Err(err) => { - gst::error!( - CAT, - imp: self, - "Couldn't build file name, err: {:?}", err, - ); - - return None; - } - }; - - gst::trace!( - CAT, - imp: self, - "Segment location formatted: {}", - location - ); - - let stream = match self - .obj() - .emit_by_name::>(SIGNAL_GET_FRAGMENT_STREAM, &[&location]) - { - Some(stream) => stream, - None => return None, - }; - - Some((stream, location)) - } - - pub fn get_segment_uri(&self, location: &str) -> String { - let settings = self.settings.lock().unwrap(); - let file_name = path::Path::new(&location) - .file_name() - .unwrap() - .to_str() - .unwrap(); - - if let Some(playlist_root) = &settings.playlist_root { - format!("{playlist_root}/{file_name}") - } else { - file_name.to_string() - } - } - - pub fn add_segment( - &self, - location: &str, - running_time: Option, - mut segment: MediaSegment, - ) -> Result { - let mut state = self.state.lock().unwrap(); - let context = match state.context.as_mut() { - Some(context) => context, - None => { - gst::error!( - CAT, - imp: self, - "Playlist is not configured", - ); - - return Err(gst::FlowError::Error); - } - }; - - if let Some(running_time) = running_time { - if context.pdt_base_running_time.is_none() { - context.pdt_base_running_time = Some(running_time); - } - - let settings = self.settings.lock().unwrap(); - - // Calculate the mapping from running time to UTC - // calculate pdt_base_utc for each segment for !pdt_follows_pipeline_clock - // when pdt_follows_pipeline_clock is set, we calculate the base time every time - // this avoids the drift between pdt tag and external clock (if gst clock has skew w.r.t external clock) - if context.pdt_base_utc.is_none() || !settings.pdt_follows_pipeline_clock { - let obj = self.obj(); - let now_utc = Utc::now(); - let now_gst = obj.clock().unwrap().time().unwrap(); - let pts_clock_time = running_time + obj.base_time().unwrap(); - - let diff = now_gst.nseconds() as i64 - pts_clock_time.nseconds() as i64; - let pts_utc = now_utc - .checked_sub_signed(Duration::nanoseconds(diff)) - .expect("offsetting the utc with gstreamer clock-diff overflow"); - - context.pdt_base_utc = Some(pts_utc); - } - - if settings.enable_program_date_time { - // Add the diff of running time to UTC time - // date_time = first_segment_utc + (current_seg_running_time - first_seg_running_time) - let date_time = - context - .pdt_base_utc - .unwrap() - .checked_add_signed(Duration::nanoseconds( - running_time - .opt_checked_sub(context.pdt_base_running_time) - .unwrap() - .unwrap() - .nseconds() as i64, - )); - - if let Some(date_time) = date_time { - segment.program_date_time = Some(date_time.into()); - } - } - } - - context.playlist.add_segment(segment); - - if context.playlist.is_type_undefined() { - context.old_segment_locations.push(location.to_string()); - } - - self.write_playlist(context) - } - - fn write_playlist( - &self, - context: &mut PlaylistContext, - ) -> Result { - gst::info!(CAT, imp: self, "Preparing to write new playlist, COUNT {}", context.playlist.len()); - - context - .playlist - .update_playlist_state(context.playlist_length as usize); - - // Acquires the playlist file handle so we can update it with new content. By default, this - // is expected to be the same file every time. - let mut playlist_stream = self - .obj() - .emit_by_name::>( - SIGNAL_GET_PLAYLIST_STREAM, - &[&context.playlist_location], - ) - .ok_or_else(|| { - gst::error!( - CAT, - imp: self, - "Could not get stream to write playlist content", - ); - gst::FlowError::Error - })? - .into_write(); - - context - .playlist - .write_to(&mut playlist_stream) - .map_err(|err| { - gst::error!( - CAT, - imp: self, - "Could not write new playlist: {}", - err.to_string() - ); - gst::FlowError::Error - })?; - playlist_stream.flush().map_err(|err| { - gst::error!( - CAT, - imp: self, - "Could not flush playlist: {}", - err.to_string() - ); - gst::FlowError::Error - })?; - - if context.playlist.is_type_undefined() && context.max_num_segment_files > 0 { - // Cleanup old segments from filesystem - while context.old_segment_locations.len() > context.max_num_segment_files { - let old_segment_location = context.old_segment_locations.remove(0); - if !self - .obj() - .emit_by_name::(SIGNAL_DELETE_FRAGMENT, &[&old_segment_location]) - { - gst::error!(CAT, imp: self, "Could not delete fragment"); - } - } - } - - gst::debug!(CAT, imp: self, "Wrote new playlist file!"); - Ok(gst::FlowSuccess::Ok) - } - - pub fn new_file_stream

(&self, location: &P) -> Result - where - P: AsRef, - { - let file = fs::File::create(location).map_err(move |err| { - let error_msg = gst::error_msg!( - gst::ResourceError::OpenWrite, - [ - "Could not open file {} for writing: {}", - location.as_ref().to_str().unwrap(), - err.to_string(), - ] - ); - self.post_error_message(error_msg); - err.to_string() - })?; - Ok(gio::WriteOutputStream::new(file).upcast()) - } - - fn delete_fragment

(&self, location: &P) - where - P: AsRef, - { - let _ = fs::remove_file(location).map_err(|err| { - gst::warning!( - CAT, - imp: self, - "Could not delete segment file: {}", - err.to_string() - ); - }); - } -} - -struct HlsSink3Settings { - location: String, - target_duration: u32, - playlist_type: Option, - i_frames_only: bool, - send_keyframe_requests: bool, - - splitmuxsink: gst::Element, - giostreamsink: gst::Element, - video_sink: bool, - audio_sink: bool, -} - -impl Default for HlsSink3Settings { - fn default() -> Self { - let muxer = gst::ElementFactory::make("mpegtsmux") - .name("mpeg-ts_mux") - .build() - .expect("Could not make element mpegtsmux"); - let giostreamsink = gst::ElementFactory::make("giostreamsink") - .name("giostream_sink") - .build() - .expect("Could not make element giostreamsink"); - let splitmuxsink = gst::ElementFactory::make("splitmuxsink") - .name("split_mux_sink") - .property("muxer", &muxer) - .property("reset-muxer", false) - .property("send-keyframe-requests", DEFAULT_SEND_KEYFRAME_REQUESTS) - .property( - "max-size-time", - gst::ClockTime::from_seconds(DEFAULT_TARGET_DURATION as u64), - ) - .property("sink", &giostreamsink) - .build() - .expect("Could not make element splitmuxsink"); - - // giostreamsink doesn't let go of its stream until the element is finalized, which might - // be too late for the calling application. Let's try to force it to close while tearing - // down the pipeline. - if giostreamsink.has_property("close-on-stop", Some(bool::static_type())) { - giostreamsink.set_property("close-on-stop", true); - } else { - gst::warning!( - CAT, - "hlssink3 may sometimes fail to write out the final playlist update. This can be fixed by using giostreamsink from GStreamer 1.24 or later." - ) - } - - Self { - location: String::from(DEFAULT_TS_LOCATION), - target_duration: DEFAULT_TARGET_DURATION, - playlist_type: None, - send_keyframe_requests: DEFAULT_SEND_KEYFRAME_REQUESTS, - i_frames_only: DEFAULT_I_FRAMES_ONLY_PLAYLIST, - - splitmuxsink, - giostreamsink, - video_sink: false, - audio_sink: false, - } - } -} - -#[derive(Default)] -struct HlsSink3State { - fragment_opened_at: Option, - fragment_running_time: Option, - current_segment_location: Option, -} - -#[derive(Default)] -pub struct HlsSink3 { - settings: Mutex, - state: Mutex, -} - -#[glib::object_subclass] -impl ObjectSubclass for HlsSink3 { - const NAME: &'static str = "GstHlsSink3"; - type Type = super::HlsSink3; - type ParentType = super::HlsBaseSink; -} - -impl ObjectImpl for HlsSink3 { - fn properties() -> &'static [glib::ParamSpec] { - static PROPERTIES: Lazy> = Lazy::new(|| { - vec![ - glib::ParamSpecString::builder("location") - .nick("File Location") - .blurb("Location of the file to write") - .default_value(Some(DEFAULT_TS_LOCATION)) - .build(), - glib::ParamSpecUInt::builder("target-duration") - .nick("Target duration") - .blurb("The target duration in seconds of a segment/file. (0 - disabled, useful for management of segment duration by the streaming server)") - .default_value(DEFAULT_TARGET_DURATION) - .build(), - glib::ParamSpecEnum::builder_with_default("playlist-type", DEFAULT_PLAYLIST_TYPE) - .nick("Playlist Type") - .blurb("The type of the playlist to use. When VOD type is set, the playlist will be live until the pipeline ends execution.") - .build(), - glib::ParamSpecBoolean::builder("i-frames-only") - .nick("I-Frames only playlist") - .blurb("Each video segments is single iframe, So put EXT-X-I-FRAMES-ONLY tag in the playlist") - .default_value(DEFAULT_I_FRAMES_ONLY_PLAYLIST) - .build(), - glib::ParamSpecBoolean::builder("send-keyframe-requests") - .nick("Send Keyframe Requests") - .blurb("Send keyframe requests to ensure correct fragmentation. If this is disabled then the input must have keyframes in regular intervals.") - .default_value(DEFAULT_SEND_KEYFRAME_REQUESTS) - .build(), - ] - }); - - PROPERTIES.as_ref() - } - - fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { - let mut settings = self.settings.lock().unwrap(); - match pspec.name() { - "location" => { - settings.location = value - .get::>() - .expect("type checked upstream") - .unwrap_or_else(|| DEFAULT_TS_LOCATION.into()); - settings - .splitmuxsink - .set_property("location", &settings.location); - } - "target-duration" => { - settings.target_duration = value.get().expect("type checked upstream"); - settings.splitmuxsink.set_property( - "max-size-time", - gst::ClockTime::from_seconds(settings.target_duration as u64), - ); - } - "playlist-type" => { - settings.playlist_type = value - .get::() - .expect("type checked upstream") - .into(); - } - "i-frames-only" => { - settings.i_frames_only = value.get().expect("type checked upstream"); - if settings.i_frames_only && settings.audio_sink { - gst::element_error!( - self.obj(), - gst::StreamError::WrongType, - ("Invalid configuration"), - ["Audio not allowed for i-frames-only-stream"] - ); - } - } - "send-keyframe-requests" => { - settings.send_keyframe_requests = value.get().expect("type checked upstream"); - settings - .splitmuxsink - .set_property("send-keyframe-requests", settings.send_keyframe_requests); - } - _ => unimplemented!(), - }; - } - - fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { - let settings = self.settings.lock().unwrap(); - match pspec.name() { - "location" => settings.location.to_value(), - "target-duration" => settings.target_duration.to_value(), - "playlist-type" => { - let playlist_type: HlsSink3PlaylistType = settings.playlist_type.as_ref().into(); - playlist_type.to_value() - } - "i-frames-only" => settings.i_frames_only.to_value(), - "send-keyframe-requests" => settings.send_keyframe_requests.to_value(), - _ => unimplemented!(), - } - } - - fn constructed(&self) { - self.parent_constructed(); - - let obj = self.obj(); - let settings = self.settings.lock().unwrap(); - - obj.add(&settings.splitmuxsink).unwrap(); - settings - .splitmuxsink - .connect("format-location-full", false, { - let imp_weak = self.downgrade(); - move |args| { - let Some(imp) = imp_weak.upgrade() else { - return Some(None::.to_value()); - }; - let fragment_id = args[1].get::().unwrap(); - gst::info!(CAT, imp: imp, "Got fragment-id: {}", fragment_id); - - let sample = args[2].get::().unwrap(); - let buffer = sample.buffer(); - let running_time = if let Some(buffer) = buffer { - let segment = sample - .segment() - .expect("segment not available") - .downcast_ref::() - .expect("no time segment"); - segment.to_running_time(buffer.pts().unwrap()) - } else { - gst::warning!( - CAT, - imp: imp, - "buffer null for fragment-id: {}", - fragment_id - ); - None - }; - - match imp.on_format_location(fragment_id, running_time) { - Ok(segment_location) => Some(segment_location.to_value()), - Err(err) => { - gst::error!(CAT, imp: imp, "on format-location handler: {}", err); - Some("unknown_segment".to_value()) - } - } - } - }); - } -} - -impl GstObjectImpl for HlsSink3 {} - -impl ElementImpl for HlsSink3 { - fn metadata() -> Option<&'static gst::subclass::ElementMetadata> { - static ELEMENT_METADATA: Lazy = Lazy::new(|| { - gst::subclass::ElementMetadata::new( - "HTTP Live Streaming sink", - "Sink/Muxer", - "HTTP Live Streaming sink", - "Alessandro Decina , \ - Sebastian Dröge , \ - Rafael Caricio ", - ) - }); - - Some(&*ELEMENT_METADATA) - } - - fn pad_templates() -> &'static [gst::PadTemplate] { - static PAD_TEMPLATES: Lazy> = Lazy::new(|| { - let caps = gst::Caps::new_any(); - let video_pad_template = gst::PadTemplate::new( - "video", - gst::PadDirection::Sink, - gst::PadPresence::Request, - &caps, - ) - .unwrap(); - - let caps = gst::Caps::new_any(); - let audio_pad_template = gst::PadTemplate::new( - "audio", - gst::PadDirection::Sink, - gst::PadPresence::Request, - &caps, - ) - .unwrap(); - - vec![video_pad_template, audio_pad_template] - }); - - PAD_TEMPLATES.as_ref() - } - - fn change_state( - &self, - transition: gst::StateChange, - ) -> Result { - if transition == gst::StateChange::ReadyToPaused { - let (target_duration, playlist_type, i_frames_only, segment_template) = { - let settings = self.settings.lock().unwrap(); - ( - settings.target_duration, - settings.playlist_type.clone(), - settings.i_frames_only, - settings.location.clone(), - ) - }; - - let playlist = self.start(target_duration, playlist_type, i_frames_only); - base_imp!(self).open_playlist(playlist, segment_template); - } - - self.parent_change_state(transition) - } - - fn request_new_pad( - &self, - templ: &gst::PadTemplate, - _name: Option<&str>, - _caps: Option<&gst::Caps>, - ) -> Option { - let mut settings = self.settings.lock().unwrap(); - match templ.name_template() { - "audio" => { - if settings.audio_sink { - gst::debug!( - CAT, - imp: self, - "requested_new_pad: audio pad is already set" - ); - return None; - } - if settings.i_frames_only { - gst::element_error!( - self.obj(), - gst::StreamError::WrongType, - ("Invalid configuration"), - ["Audio not allowed for i-frames-only-stream"] - ); - return None; - } - - let peer_pad = settings.splitmuxsink.request_pad_simple("audio_0").unwrap(); - let sink_pad = gst::GhostPad::from_template_with_target(templ, &peer_pad).unwrap(); - self.obj().add_pad(&sink_pad).unwrap(); - sink_pad.set_active(true).unwrap(); - settings.audio_sink = true; - - Some(sink_pad.upcast()) - } - "video" => { - if settings.video_sink { - gst::debug!( - CAT, - imp: self, - "requested_new_pad: video pad is already set" - ); - return None; - } - let peer_pad = settings.splitmuxsink.request_pad_simple("video").unwrap(); - - let sink_pad = gst::GhostPad::from_template_with_target(templ, &peer_pad).unwrap(); - self.obj().add_pad(&sink_pad).unwrap(); - sink_pad.set_active(true).unwrap(); - settings.video_sink = true; - - Some(sink_pad.upcast()) - } - other_name => { - gst::debug!( - CAT, - imp: self, - "requested_new_pad: name \"{}\" is not audio or video", - other_name - ); - None - } - } - } - - fn release_pad(&self, pad: &gst::Pad) { - let mut settings = self.settings.lock().unwrap(); - - if !settings.audio_sink && !settings.video_sink { - return; - } - - let ghost_pad = pad.downcast_ref::().unwrap(); - if let Some(peer) = ghost_pad.target() { - settings.splitmuxsink.release_request_pad(&peer); - } - - pad.set_active(false).unwrap(); - self.obj().remove_pad(pad).unwrap(); - - if "audio" == ghost_pad.name() { - settings.audio_sink = false; - } else { - settings.video_sink = false; - } - } -} - -impl BinImpl for HlsSink3 { - #[allow(clippy::single_match)] - fn handle_message(&self, msg: gst::Message) { - use gst::MessageView; - - match msg.view() { - MessageView::Element(msg) => { - let event_is_from_splitmuxsink = { - let settings = self.settings.lock().unwrap(); - - msg.src() == Some(settings.splitmuxsink.upcast_ref()) - }; - if !event_is_from_splitmuxsink { - return; - } - - let s = msg.structure().unwrap(); - match s.name().as_str() { - "splitmuxsink-fragment-opened" => { - if let Ok(new_fragment_opened_at) = s.get::("running-time") - { - let mut state = self.state.lock().unwrap(); - state.fragment_opened_at = Some(new_fragment_opened_at); - } - } - "splitmuxsink-fragment-closed" => { - let s = msg.structure().unwrap(); - if let Ok(fragment_closed_at) = s.get::("running-time") { - self.on_fragment_closed(fragment_closed_at); - } - } - _ => {} - } - } - _ => self.parent_handle_message(msg), - } - } -} - -impl HlsBaseSinkImpl for HlsSink3 {} - -impl HlsSink3 { - fn start( - &self, - target_duration: u32, - playlist_type: Option, - i_frames_only: bool, - ) -> Playlist { - gst::info!(CAT, imp: self, "Starting"); - - let mut state = self.state.lock().unwrap(); - *state = HlsSink3State::default(); - - let (turn_vod, playlist_type) = if playlist_type == Some(MediaPlaylistType::Vod) { - (true, Some(MediaPlaylistType::Event)) - } else { - (false, playlist_type) - }; - - let playlist = MediaPlaylist { - version: if i_frames_only { Some(4) } else { Some(3) }, - target_duration: target_duration as f32, - playlist_type, - i_frames_only, - ..Default::default() - }; - - Playlist::new(playlist, turn_vod, false) - } - - fn on_format_location( - &self, - fragment_id: u32, - running_time: Option, - ) -> Result { - gst::info!( - CAT, - imp: self, - "Starting the formatting of the fragment-id: {}", - fragment_id - ); - - let (fragment_stream, segment_file_location) = base_imp!(self) - .get_fragment_stream(fragment_id) - .ok_or_else(|| String::from("Error while getting fragment stream"))?; - - let mut state = self.state.lock().unwrap(); - state.current_segment_location = Some(segment_file_location.clone()); - state.fragment_running_time = running_time; - - let settings = self.settings.lock().unwrap(); - settings - .giostreamsink - .set_property("stream", &fragment_stream); - - gst::info!( - CAT, - imp: self, - "New segment location: {:?}", - state.current_segment_location.as_ref() - ); - - Ok(segment_file_location) - } - - fn on_fragment_closed(&self, closed_at: gst::ClockTime) { - let mut state = self.state.lock().unwrap(); - let location = match state.current_segment_location.take() { - Some(location) => location, - None => { - gst::error!(CAT, imp: self, "Unknown segment location"); - return; - } - }; - - let opened_at = match state.fragment_opened_at.take() { - Some(opened_at) => opened_at, - None => { - gst::error!(CAT, imp: self, "Unknown segment duration"); - return; - } - }; - - let duration = ((closed_at - opened_at).mseconds() as f32) / 1_000f32; - let running_time = state.fragment_running_time; - drop(state); - - let obj = self.obj(); - let base_imp = obj.upcast_ref::().imp(); - let uri = base_imp.get_segment_uri(&location); - let _ = base_imp.add_segment( - &location, - running_time, - MediaSegment { - uri, - duration, - ..Default::default() - }, - ); - } -} - -struct HlsCmafSinkSettings { - init_location: String, - location: String, - target_duration: u32, - playlist_type: Option, - sync: bool, - latency: gst::ClockTime, - - cmafmux: gst::Element, - appsink: gst_app::AppSink, -} - -impl Default for HlsCmafSinkSettings { - fn default() -> Self { - let cmafmux = gst::ElementFactory::make("cmafmux") - .name("muxer") - .property( - "fragment-duration", - gst::ClockTime::from_seconds(DEFAULT_TARGET_DURATION as u64), - ) - .property("latency", DEFAULT_LATENCY) - .build() - .expect("Could not make element cmafmux"); - let appsink = gst_app::AppSink::builder() - .buffer_list(true) - .sync(DEFAULT_SYNC) - .name("sink") - .build(); - - Self { - init_location: String::from(DEFAULT_INIT_LOCATION), - location: String::from(DEFAULT_CMAF_LOCATION), - target_duration: DEFAULT_TARGET_DURATION, - playlist_type: None, - sync: DEFAULT_SYNC, - latency: DEFAULT_LATENCY, - cmafmux, - appsink, - } - } -} - -#[derive(Default)] -struct HlsCmafSinkState { - init_idx: u32, - segment_idx: u32, - init_segment: Option, - new_header: bool, -} - -#[derive(Default)] -pub struct HlsCmafSink { - settings: Mutex, - state: Mutex, -} - -#[glib::object_subclass] -impl ObjectSubclass for HlsCmafSink { - const NAME: &'static str = "GstHlsCmafSink"; - type Type = super::HlsCmafSink; - type ParentType = super::HlsBaseSink; -} - -impl ObjectImpl for HlsCmafSink { - fn properties() -> &'static [glib::ParamSpec] { - static PROPERTIES: Lazy> = Lazy::new(|| { - vec![ - glib::ParamSpecString::builder("init-location") - .nick("Init Location") - .blurb("Location of the init fragment file to write") - .default_value(Some(DEFAULT_INIT_LOCATION)) - .build(), - glib::ParamSpecString::builder("location") - .nick("Location") - .blurb("Location of the fragment file to write") - .default_value(Some(DEFAULT_CMAF_LOCATION)) - .build(), - glib::ParamSpecUInt::builder("target-duration") - .nick("Target duration") - .blurb("The target duration in seconds of a segment/file. (0 - disabled, useful for management of segment duration by the streaming server)") - .default_value(DEFAULT_TARGET_DURATION) - .mutable_ready() - .build(), - glib::ParamSpecEnum::builder_with_default("playlist-type", DEFAULT_PLAYLIST_TYPE) - .nick("Playlist Type") - .blurb("The type of the playlist to use. When VOD type is set, the playlist will be live until the pipeline ends execution.") - .mutable_ready() - .build(), - glib::ParamSpecBoolean::builder("sync") - .nick("Sync") - .blurb("Sync on the clock") - .default_value(DEFAULT_SYNC) - .build(), - glib::ParamSpecUInt64::builder("latency") - .nick("Latency") - .blurb( - "Additional latency to allow upstream to take longer to \ - produce buffers for the current position (in nanoseconds)", - ) - .maximum(i64::MAX as u64) - .default_value(DEFAULT_LATENCY.nseconds()) - .build(), - ] - }); - - PROPERTIES.as_ref() - } - - fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { - let mut settings = self.settings.lock().unwrap(); - match pspec.name() { - "init-location" => { - settings.init_location = value - .get::>() - .expect("type checked upstream") - .unwrap_or_else(|| DEFAULT_INIT_LOCATION.into()); - } - "location" => { - settings.location = value - .get::>() - .expect("type checked upstream") - .unwrap_or_else(|| DEFAULT_CMAF_LOCATION.into()); - } - "target-duration" => { - settings.target_duration = value.get().expect("type checked upstream"); - settings.cmafmux.set_property( - "fragment-duration", - gst::ClockTime::from_seconds(settings.target_duration as u64), - ); - } - "playlist-type" => { - settings.playlist_type = value - .get::() - .expect("type checked upstream") - .into(); - } - "sync" => { - settings.sync = value.get().expect("type checked upstream"); - settings.appsink.set_property("sync", settings.sync); - } - "latency" => { - settings.latency = value.get().expect("type checked upstream"); - settings.cmafmux.set_property("latency", settings.latency); - } - _ => unimplemented!(), - }; - } - - fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { - let settings = self.settings.lock().unwrap(); - match pspec.name() { - "init-location" => settings.init_location.to_value(), - "location" => settings.location.to_value(), - "target-duration" => settings.target_duration.to_value(), - "playlist-type" => { - let playlist_type: HlsSink3PlaylistType = settings.playlist_type.as_ref().into(); - playlist_type.to_value() - } - "sync" => settings.sync.to_value(), - "latency" => settings.latency.to_value(), - _ => unimplemented!(), - } - } - - fn signals() -> &'static [glib::subclass::Signal] { - static SIGNALS: Lazy> = Lazy::new(|| { - vec![glib::subclass::Signal::builder(SIGNAL_GET_INIT_STREAM) - .param_types([String::static_type()]) - .return_type::>() - .class_handler(|_, args| { - let elem = args[0].get::().expect("signal arg"); - let init_location = args[1].get::().expect("signal arg"); - let imp = elem.imp(); - - Some(imp.new_file_stream(&init_location).ok().to_value()) - }) - .accumulator(|_hint, ret, value| { - // First signal handler wins - *ret = value.clone(); - false - }) - .build()] - }); - - SIGNALS.as_ref() - } - - fn constructed(&self) { - self.parent_constructed(); - - let obj = self.obj(); - let settings = self.settings.lock().unwrap(); - - obj.add_many([&settings.cmafmux, settings.appsink.upcast_ref()]) - .unwrap(); - settings.cmafmux.link(&settings.appsink).unwrap(); - - let sinkpad = settings.cmafmux.static_pad("sink").unwrap(); - let gpad = gst::GhostPad::with_target(&sinkpad).unwrap(); - - obj.add_pad(&gpad).unwrap(); - - let self_weak = self.downgrade(); - settings.appsink.set_callbacks( - gst_app::AppSinkCallbacks::builder() - .new_sample(move |sink| { - let Some(imp) = self_weak.upgrade() else { - return Err(gst::FlowError::Eos); - }; - - let sample = sink.pull_sample().map_err(|_| gst::FlowError::Eos)?; - imp.on_new_sample(sample) - }) - .build(), - ); - } -} - -impl GstObjectImpl for HlsCmafSink {} - -impl ElementImpl for HlsCmafSink { - fn metadata() -> Option<&'static gst::subclass::ElementMetadata> { - static ELEMENT_METADATA: Lazy = Lazy::new(|| { - gst::subclass::ElementMetadata::new( - "HTTP Live Streaming CMAF Sink", - "Sink/Muxer", - "HTTP Live Streaming CMAF Sink", - "Seungha Yang ", - ) - }); - - Some(&*ELEMENT_METADATA) - } - - fn pad_templates() -> &'static [gst::PadTemplate] { - static PAD_TEMPLATES: Lazy> = Lazy::new(|| { - let pad_template = gst::PadTemplate::new( - "sink", - gst::PadDirection::Sink, - gst::PadPresence::Always, - &[ - gst::Structure::builder("video/x-h264") - .field("stream-format", gst::List::new(["avc", "avc3"])) - .field("alignment", "au") - .field("width", gst::IntRange::new(1, u16::MAX as i32)) - .field("height", gst::IntRange::new(1, u16::MAX as i32)) - .build(), - gst::Structure::builder("video/x-h265") - .field("stream-format", gst::List::new(["hvc1", "hev1"])) - .field("alignment", "au") - .field("width", gst::IntRange::new(1, u16::MAX as i32)) - .field("height", gst::IntRange::new(1, u16::MAX as i32)) - .build(), - gst::Structure::builder("audio/mpeg") - .field("mpegversion", 4i32) - .field("stream-format", "raw") - .field("channels", gst::IntRange::new(1, u16::MAX as i32)) - .field("rate", gst::IntRange::new(1, i32::MAX)) - .build(), - ] - .into_iter() - .collect::(), - ) - .unwrap(); - - vec![pad_template] - }); - - PAD_TEMPLATES.as_ref() - } - - fn change_state( - &self, - transition: gst::StateChange, - ) -> Result { - if transition == gst::StateChange::ReadyToPaused { - let (target_duration, playlist_type, segment_template) = { - let settings = self.settings.lock().unwrap(); - ( - settings.target_duration, - settings.playlist_type.clone(), - settings.location.clone(), - ) - }; - - let playlist = self.start(target_duration, playlist_type); - base_imp!(self).open_playlist(playlist, segment_template); - } - - self.parent_change_state(transition) - } -} - -impl BinImpl for HlsCmafSink {} - -impl HlsBaseSinkImpl for HlsCmafSink {} - -impl HlsCmafSink { - fn start(&self, target_duration: u32, playlist_type: Option) -> Playlist { - gst::info!(CAT, imp: self, "Starting"); - - let mut state = self.state.lock().unwrap(); - *state = HlsCmafSinkState::default(); - - let (turn_vod, playlist_type) = if playlist_type == Some(MediaPlaylistType::Vod) { - (true, Some(MediaPlaylistType::Event)) - } else { - (false, playlist_type) - }; - - let playlist = MediaPlaylist { - version: Some(6), - target_duration: target_duration as f32, - playlist_type, - independent_segments: true, - ..Default::default() - }; - - Playlist::new(playlist, turn_vod, true) - } - - fn on_init_segment(&self) -> Result, String> { - let settings = self.settings.lock().unwrap(); - let mut state = self.state.lock().unwrap(); - let location = match sprintf::sprintf!(&settings.init_location, state.init_idx) { - Ok(location) => location, - Err(err) => { - gst::error!( - CAT, - imp: self, - "Couldn't build file name, err: {:?}", err, - ); - return Err(String::from("Invalid init segment file pattern")); - } - }; - - let stream = self - .obj() - .emit_by_name::>(SIGNAL_GET_INIT_STREAM, &[&location]) - .ok_or_else(|| String::from("Error while getting fragment stream"))? - .into_write(); - - let uri = base_imp!(self).get_segment_uri(&location); - - state.init_segment = Some(m3u8_rs::Map { - uri, - ..Default::default() - }); - state.new_header = true; - state.init_idx += 1; - - Ok(stream) - } - - fn on_new_fragment( - &self, - ) -> Result<(gio::OutputStreamWrite, String), String> { - let mut state = self.state.lock().unwrap(); - let (stream, location) = base_imp!(self) - .get_fragment_stream(state.segment_idx) - .ok_or_else(|| String::from("Error while getting fragment stream"))?; - - state.segment_idx += 1; - - Ok((stream.into_write(), location)) - } - - fn add_segment( - &self, - duration: f32, - running_time: Option, - location: String, - ) -> Result { - let uri = base_imp!(self).get_segment_uri(&location); - let mut state = self.state.lock().unwrap(); - - let map = if state.new_header { - state.new_header = false; - state.init_segment.clone() - } else { - None - }; - - base_imp!(self).add_segment( - &location, - running_time, - MediaSegment { - uri, - duration, - map, - ..Default::default() - }, - ) - } - - fn on_new_sample(&self, sample: gst::Sample) -> Result { - let mut buffer_list = sample.buffer_list_owned().unwrap(); - let mut first = buffer_list.get(0).unwrap(); - - if first - .flags() - .contains(gst::BufferFlags::DISCONT | gst::BufferFlags::HEADER) - { - let mut stream = self.on_init_segment().map_err(|err| { - gst::error!( - CAT, - imp: self, - "Couldn't get output stream for init segment, {err}", - ); - gst::FlowError::Error - })?; - - let map = first.map_readable().unwrap(); - stream.write(&map).map_err(|_| { - gst::error!( - CAT, - imp: self, - "Couldn't write init segment to output stream", - ); - gst::FlowError::Error - })?; - - stream.flush().map_err(|_| { - gst::error!( - CAT, - imp: self, - "Couldn't flush output stream", - ); - gst::FlowError::Error - })?; - - drop(map); - - buffer_list.make_mut().remove(0, 1); - if buffer_list.is_empty() { - return Ok(gst::FlowSuccess::Ok); - } - - first = buffer_list.get(0).unwrap(); - } - - let segment = sample - .segment() - .unwrap() - .downcast_ref::() - .unwrap(); - let running_time = segment.to_running_time(first.pts().unwrap()); - let dur = first.duration().unwrap(); - - let (mut stream, location) = self.on_new_fragment().map_err(|err| { - gst::error!( - CAT, - imp: self, - "Couldn't get output stream for segment, {err}", - ); - gst::FlowError::Error - })?; - - for buffer in &*buffer_list { - let map = buffer.map_readable().unwrap(); - - stream.write(&map).map_err(|_| { - gst::error!( - CAT, - imp: self, - "Couldn't write segment to output stream", - ); - gst::FlowError::Error - })?; - } - - stream.flush().map_err(|_| { - gst::error!( - CAT, - imp: self, - "Couldn't flush output stream", - ); - gst::FlowError::Error - })?; - - self.add_segment(dur.mseconds() as f32 / 1_000f32, running_time, location) - } -} diff --git a/net/hlssink3/src/lib.rs b/net/hlssink3/src/lib.rs index 87c92f7c..750cdd8a 100644 --- a/net/hlssink3/src/lib.rs +++ b/net/hlssink3/src/lib.rs @@ -8,72 +8,31 @@ #![allow(clippy::non_send_fields_in_send_ty, unused_doc_comments)] /** - * plugin-hlssink3: + * plugin-hlssink: * * Since: plugins-rs-0.8.0 */ use gst::glib; -use gst::prelude::*; -mod imp; +mod basesink; +pub mod hlscmafsink; +pub mod hlssink3; +pub mod hlssink4; mod playlist; -#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Clone, Copy, glib::Enum)] -#[repr(u32)] -#[enum_type(name = "GstHlsSink3PlaylistType")] -#[non_exhaustive] -pub enum HlsSink3PlaylistType { - #[enum_value( - name = "Unspecified: The tag `#EXT-X-PLAYLIST-TYPE` won't be present in the playlist during the pipeline processing.", - nick = "unspecified" - )] - Unspecified = 0, - - #[enum_value( - name = "Event: No segments will be removed from the playlist. At the end of the processing, the tag `#EXT-X-ENDLIST` is added to the playlist. The tag `#EXT-X-PLAYLIST-TYPE:EVENT` will be present in the playlist.", - nick = "event" - )] - Event = 1, - - #[enum_value( - name = "Vod: The playlist behaves like the `event` option (a live event), but at the end of the processing, the playlist will be set to `#EXT-X-PLAYLIST-TYPE:VOD`.", - nick = "vod" - )] - Vod = 2, -} - glib::wrapper! { - pub struct HlsBaseSink(ObjectSubclass) @extends gst::Bin, gst::Element, gst::Object; -} - -glib::wrapper! { - pub struct HlsSink3(ObjectSubclass) @extends HlsBaseSink, gst::Bin, gst::Element, gst::Object; -} - -glib::wrapper! { - pub struct HlsCmafSink(ObjectSubclass) @extends HlsBaseSink, gst::Bin, gst::Element, gst::Object; + pub struct HlsBaseSink(ObjectSubclass) @extends gst::Bin, gst::Element, gst::Object; } pub fn plugin_init(plugin: &gst::Plugin) -> Result<(), glib::BoolError> { #[cfg(feature = "doc")] { - HlsSink3PlaylistType::static_type().mark_as_plugin_api(gst::PluginAPIFlags::empty()); HlsBaseSink::static_type().mark_as_plugin_api(gst::PluginAPIFlags::empty()); } - gst::Element::register( - Some(plugin), - "hlssink3", - gst::Rank::NONE, - HlsSink3::static_type(), - )?; - - gst::Element::register( - Some(plugin), - "hlscmafsink", - gst::Rank::NONE, - HlsCmafSink::static_type(), - )?; + hlssink3::register(plugin)?; + hlssink4::register(plugin)?; + hlscmafsink::register(plugin)?; Ok(()) } diff --git a/net/hlssink3/tests/hlssink3.rs b/net/hlssink3/tests/hlssink3.rs index 4f3ec2bc..67ec15c6 100644 --- a/net/hlssink3/tests/hlssink3.rs +++ b/net/hlssink3/tests/hlssink3.rs @@ -8,7 +8,7 @@ use gio::prelude::*; use gst::prelude::*; -use gsthlssink3::HlsSink3PlaylistType; +use gsthlssink3::hlssink3::HlsSink3PlaylistType; use once_cell::sync::Lazy; use std::io::Write; use std::sync::{mpsc, Arc, Mutex}; diff --git a/net/hlssink3/tests/hlssink4.rs b/net/hlssink3/tests/hlssink4.rs new file mode 100644 index 00000000..041681ff --- /dev/null +++ b/net/hlssink3/tests/hlssink4.rs @@ -0,0 +1,1136 @@ +// Copyright (C) 2024, asymptotic.io +// Author: Sanchayan Maity +// +// This Source Code Form is subject to the terms of the Mozilla Public License, v2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at +// . +// +// SPDX-License-Identifier: MPL-2.0 + +/* + * Written on the same lines as the `hlssink3` tests. + */ + +use gio::prelude::*; +use gst::prelude::*; +use gsthlssink3::hlssink4::{HlsSink4MuxerType, HlsSink4PlaylistType}; +use once_cell::sync::Lazy; +use std::io::Write; +use std::str::FromStr; +use std::sync::mpsc::SyncSender; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; +use std::{collections::HashSet, hash::Hash}; + +fn is_playlist_events_eq(a: &[T], b: &[T]) -> bool +where + T: Eq + Hash + std::fmt::Debug, +{ + let a: HashSet<_> = a.iter().collect(); + let b: HashSet<_> = b.iter().collect(); + + a == b +} + +static CAT: Lazy = Lazy::new(|| { + gst::DebugCategory::new( + "hlssink4-test", + gst::DebugColorFlags::empty(), + Some("HLS sink test"), + ) +}); + +macro_rules! try_create_element { + ($l:expr, $n:expr) => { + match gst::ElementFactory::find($l) { + Some(factory) => Ok(factory.create().name($n).build().unwrap()), + None => { + eprintln!("Could not find {} ({}) plugin, skipping test", $l, $n); + return Err(()); + } + } + }; + ($l:expr) => {{ + let alias: String = format!("test_{}", $l); + try_create_element!($l, >::as_ref(&alias)) + }}; +} + +fn init() { + use std::sync::Once; + static INIT: Once = Once::new(); + + INIT.call_once(|| { + gst::init().unwrap(); + gsthlssink3::plugin_register_static().expect("hlssink4 test"); + }); +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +enum HlsSinkEvent { + DeleteFragment(String), + GetMasterPlaylistStream(String), + GetFragmentStream(String), + GetInitStream(String), + GetPlaylistStream(String), +} + +struct MemoryPlaylistFile { + handler: Arc>, +} + +impl MemoryPlaylistFile { + fn clear_content(&self) { + let mut string = self.handler.lock().unwrap(); + string.clear(); + } +} + +impl Write for MemoryPlaylistFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let value = std::str::from_utf8(buf).unwrap(); + let mut string = self.handler.lock().unwrap(); + string.push_str(value); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn video_bin( + width: u32, + height: u32, + fps: u32, + bitrate: u32, + iframe_only: bool, +) -> Result { + const BUFFER_NB: i32 = 62; + + let bin = gst::Bin::new(); + + let videosrc = try_create_element!("videotestsrc")?; + let videoconvert = try_create_element!("videoconvert")?; + let videoscale = try_create_element!("videoscale")?; + let videorate = try_create_element!("videorate")?; + let capsfilter = try_create_element!("capsfilter")?; + let x264enc = try_create_element!("x264enc")?; + let h264parse = try_create_element!("h264parse")?; + let queue = try_create_element!("queue")?; + + videosrc.set_property("is-live", true); + videosrc.set_property_from_str("pattern", "ball"); + videosrc.set_property("num-buffers", BUFFER_NB); + + let caps = gst::Caps::from_str(format!("video/x-raw,width={width},height={height},framerate={fps}/1,pixel-aspect-ratio=1/1,format=I420").as_str()).unwrap(); + capsfilter.set_property("caps", caps); + + x264enc.set_property("bitrate", bitrate); + if iframe_only { + x264enc.set_property("key-int-max", 1); + } else { + x264enc.set_property("key-int-max", fps * 2); + } + + bin.add(&videosrc).unwrap(); + bin.add(&videoconvert).unwrap(); + bin.add(&videoscale).unwrap(); + bin.add(&videorate).unwrap(); + bin.add(&capsfilter).unwrap(); + bin.add(&x264enc).unwrap(); + bin.add(&h264parse).unwrap(); + bin.add(&queue).unwrap(); + + videosrc.link(&videoconvert).unwrap(); + videoconvert.link(&videorate).unwrap(); + videorate.link(&videoscale).unwrap(); + videoscale.link(&capsfilter).unwrap(); + capsfilter.link(&x264enc).unwrap(); + x264enc.link(&h264parse).unwrap(); + h264parse.link(&queue).unwrap(); + + let queue_srcpad = queue.static_pad("src").unwrap(); + + let srcpad = gst::GhostPad::with_target(&queue_srcpad).unwrap(); + + bin.add_pad(&srcpad).unwrap(); + + Ok(bin) +} + +fn audio_bin(bitrate: i32) -> Result { + const BUFFER_NB: i32 = 62; + + let bin = gst::Bin::new(); + + let audiosrc = try_create_element!("audiotestsrc")?; + let audioconvert = try_create_element!("audioconvert")?; + let audiorate = try_create_element!("audiorate")?; + let capsfilter = try_create_element!("capsfilter")?; + let aacenc = try_create_element!("avenc_aac")?; + let aacparse = try_create_element!("aacparse")?; + let queue = try_create_element!("queue")?; + + let caps = gst::Caps::from_str("audio/x-raw,channels=2,rate=48000,format=F32LE").unwrap(); + + audiosrc.set_property("is-live", true); + audiosrc.set_property("num-buffers", BUFFER_NB); + capsfilter.set_property("caps", caps); + aacenc.set_property("bitrate", bitrate); + + bin.add(&audiosrc).unwrap(); + bin.add(&audioconvert).unwrap(); + bin.add(&audiorate).unwrap(); + bin.add(&capsfilter).unwrap(); + bin.add(&aacenc).unwrap(); + bin.add(&aacparse).unwrap(); + bin.add(&queue).unwrap(); + + audiosrc.link(&audioconvert).unwrap(); + audioconvert.link(&audiorate).unwrap(); + audiorate.link(&capsfilter).unwrap(); + capsfilter.link(&aacenc).unwrap(); + aacenc.link(&aacparse).unwrap(); + aacparse.link(&queue).unwrap(); + + let queue_srcpad = queue.static_pad("src").unwrap(); + + let srcpad = gst::GhostPad::with_target(&queue_srcpad).unwrap(); + + bin.add_pad(&srcpad).unwrap(); + + Ok(bin) +} + +/* +* For the tests below the directory structure would be expected to be +* as follows. +* +* master playlist will have the path /tmp/hlssink/master.m3u8. Every +* other variant stream or alternate rendition will be relative to +* /tmp/hlssink. +* + # tmp → hlssink λ: + . + ├── hi + ├── hi-audio + ├── low + ├── low-audio + ├── mid + ├── mid-audio +* +*/ + +fn setup_signals( + hlssink4: &gst::Element, + hls_events_sender: SyncSender, + master_playlist_content: Arc>, + playlist_content: Arc>, + muxer_type: HlsSink4MuxerType, +) { + hlssink4.connect("get-master-playlist-stream", false, { + let hls_events_sender = hls_events_sender.clone(); + let master_playlist_content = master_playlist_content.clone(); + move |args| { + let location = args[1].get::().expect("No location given"); + + gst::info!(CAT, "get-master-playlist-stream: {}", location); + + hls_events_sender + .try_send(HlsSinkEvent::GetMasterPlaylistStream(location)) + .expect("Send master playlist event"); + + let playlist = MemoryPlaylistFile { + handler: Arc::clone(&master_playlist_content), + }; + + playlist.clear_content(); + let output = gio::WriteOutputStream::new(playlist); + Some(output.to_value()) + } + }); + + if muxer_type == HlsSink4MuxerType::Cmaf { + hlssink4.connect("get-init-stream", false, { + let hls_events_sender = hls_events_sender.clone(); + move |args| { + let location = args[1].get::().expect("No location given"); + + hls_events_sender + .try_send(HlsSinkEvent::GetInitStream(location)) + .expect("Send init event"); + + let stream = gio::MemoryOutputStream::new_resizable(); + Some(stream.to_value()) + } + }); + } + + hlssink4.connect("get-playlist-stream", false, { + let hls_events_sender = hls_events_sender.clone(); + let playlist_content = playlist_content.clone(); + move |args| { + let location = args[1].get::().expect("No location given"); + + hls_events_sender + .try_send(HlsSinkEvent::GetPlaylistStream(location)) + .expect("Send playlist event"); + + let playlist = MemoryPlaylistFile { + handler: Arc::clone(&playlist_content), + }; + + playlist.clear_content(); + let output = gio::WriteOutputStream::new(playlist); + Some(output.to_value()) + } + }); + + hlssink4.connect("get-fragment-stream", false, { + let hls_events_sender = hls_events_sender.clone(); + move |args| { + let location = args[1].get::().expect("No location given"); + + hls_events_sender + .try_send(HlsSinkEvent::GetFragmentStream(location)) + .expect("Send fragment event"); + + let stream = gio::MemoryOutputStream::new_resizable(); + Some(stream.to_value()) + } + }); + + hlssink4.connect("delete-fragment", false, move |args| { + let location = args[1].get::().expect("No location given"); + hls_events_sender + .try_send(HlsSinkEvent::DeleteFragment(location)) + .expect("Send delete fragment event"); + Some(true.to_value()) + }); +} + +#[test] +fn hlssink4_multiple_audio_rendition_multiple_video_variant() -> Result<(), ()> { + init(); + + let pipeline = gst::Pipeline::with_name("hlssink4_pipeline"); + + let hlssink4 = gst::ElementFactory::make("hlssink4") + .name("test_hlssink4") + .property("master-playlist-location", "/tmp/hlssink/master.m3u8") + .property("target-duration", 2u32) + .property("playlist-length", 2u32) + .property("max-files", 2u32) + .build() + .expect("Must be able to instantiate hlssink4"); + + hlssink4.set_property("playlist-type", HlsSink4PlaylistType::Event); + let pl_type: HlsSink4PlaylistType = hlssink4.property("playlist-type"); + assert_eq!(pl_type, HlsSink4PlaylistType::Event); + + hlssink4.set_property_from_str("playlist-type", "unspecified"); + + let muxer_type: HlsSink4MuxerType = hlssink4.property("muxer-type"); + assert_eq!(muxer_type, HlsSink4MuxerType::Cmaf); + + pipeline.add(&hlssink4).unwrap(); + + let (hls_events_sender, hls_events_receiver) = mpsc::sync_channel(100); + let master_playlist_content = Arc::new(Mutex::new(String::from(""))); + let playlist_content = Arc::new(Mutex::new(String::from(""))); + + setup_signals( + &hlssink4, + hls_events_sender.clone(), + master_playlist_content.clone(), + playlist_content.clone(), + HlsSink4MuxerType::Cmaf, + ); + + let audio_bin1 = audio_bin(256000).unwrap(); + let audio_bin1_pad = audio_bin1.static_pad("src").unwrap(); + let audio1_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let r = gst::Structure::builder("audio1-rendition") + .field("media", "AUDIO") + .field("uri", "hi-audio/audio.m3u8") + .field("group_id", "aac") + .field("language", "en") + .field("name", "English") + .field("default", true) + .field("autoselect", false) + .build(); + audio1_pad.set_property("alternate-rendition", r); + pipeline.add(&audio_bin1).unwrap(); + audio_bin1_pad.link(&audio1_pad).unwrap(); + + let audio_bin2 = audio_bin(128000).unwrap(); + let audio_bin2_pad = audio_bin2.static_pad("src").unwrap(); + let audio2_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let r = gst::Structure::builder("audio2-rendition") + .field("media", "AUDIO") + .field("uri", "mid-audio/audio.m3u8") + .field("group_id", "aac") + .field("language", "fr") + .field("name", "French") + .field("default", false) + .field("autoselect", false) + .build(); + audio2_pad.set_property("alternate-rendition", r); + pipeline.add(&audio_bin2).unwrap(); + audio_bin2_pad.link(&audio2_pad).unwrap(); + + let video_bin1 = video_bin(1920, 1080, 30, 2500, false).unwrap(); + let video_bin1_pad = video_bin1.static_pad("src").unwrap(); + let video1_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video1-variant") + .field("uri", "hi/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 2500) + .build(); + video1_pad.set_property("variant", v); + pipeline.add(&video_bin1).unwrap(); + video_bin1_pad.link(&video1_pad).unwrap(); + + let video_bin2 = video_bin(1280, 720, 30, 1500, false).unwrap(); + let video_bin2_pad = video_bin2.static_pad("src").unwrap(); + let video2_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video2-variant") + .field("uri", "mid/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 1500) + .build(); + video2_pad.set_property("variant", v); + pipeline.add(&video_bin2).unwrap(); + video_bin2_pad.link(&video2_pad).unwrap(); + + let video_bin3 = video_bin(640, 360, 24, 700, false).unwrap(); + let video_bin3_pad = video_bin3.static_pad("src").unwrap(); + let video3_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video2-variant") + .field("uri", "low/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 700) + .build(); + video3_pad.set_property("variant", v); + pipeline.add(&video_bin3).unwrap(); + video_bin3_pad.link(&video3_pad).unwrap(); + + pipeline.set_state(gst::State::Playing).unwrap(); + + let mut eos = false; + let bus = pipeline.bus().unwrap(); + while let Some(msg) = bus.timed_pop(gst::ClockTime::NONE) { + use gst::MessageView; + match msg.view() { + MessageView::Eos(..) => { + eos = true; + break; + } + MessageView::Error(e) => gst::error!(CAT, "hlssink4 error: {}", e), + _ => (), + } + } + + pipeline.debug_to_dot_file_with_ts( + gst::DebugGraphDetails::all(), + "multiple_audio_rendition_multiple_video_variant", + ); + + pipeline.set_state(gst::State::Null).unwrap(); + assert!(eos); + + let mut actual_events = Vec::new(); + while let Ok(event) = hls_events_receiver.recv_timeout(Duration::from_millis(1)) { + actual_events.push(event); + } + let expected_events = { + use self::HlsSinkEvent::*; + vec![ + GetMasterPlaylistStream("/tmp/hlssink/master.m3u8".to_string()), + GetInitStream("/tmp/hlssink/hi/init00000.mp4".to_string()), + GetInitStream("/tmp/hlssink/mid/init00000.mp4".to_string()), + GetInitStream("/tmp/hlssink/low/init00000.mp4".to_string()), + GetInitStream("/tmp/hlssink/hi-audio/init00000.mp4".to_string()), + GetInitStream("/tmp/hlssink/mid-audio/init00000.mp4".to_string()), + GetPlaylistStream("/tmp/hlssink/hi/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/mid/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/low/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/hi-audio/audio.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/mid-audio/audio.m3u8".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00001.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/mid/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/mid/segment00001.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/low/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/low/segment00001.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/hi-audio/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/hi-audio/segment00001.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/mid-audio/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/mid-audio/segment00001.m4s".to_string()), + ] + }; + assert!(is_playlist_events_eq(&expected_events, &actual_events)); + + let contents = master_playlist_content.lock().unwrap(); + + #[rustfmt::skip] + assert_eq!( + r###"#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-MEDIA:TYPE=AUDIO,URI="hi-audio/audio.m3u8",GROUP-ID="aac",LANGUAGE="en",NAME="English",DEFAULT=YES +#EXT-X-MEDIA:TYPE=AUDIO,URI="mid-audio/audio.m3u8",GROUP-ID="aac",LANGUAGE="fr",NAME="French" +#EXT-X-STREAM-INF:BANDWIDTH=2500,CODECS="avc1.64001E,avc1.64001F,avc1.640028,mp4a.40.2",AUDIO="aac" +hi/video.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=1500,CODECS="avc1.64001E,avc1.64001F,avc1.640028,mp4a.40.2",AUDIO="aac" +mid/video.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=700,CODECS="avc1.64001E,avc1.64001F,avc1.640028,mp4a.40.2",AUDIO="aac" +low/video.m3u8 +"###, + contents.to_string() + ); + + Ok(()) +} + +#[test] +fn hlssink4_multiple_audio_rendition_single_video_variant() -> Result<(), ()> { + init(); + + let pipeline = gst::Pipeline::with_name("hlssink4_pipeline"); + + let hlssink4 = gst::ElementFactory::make("hlssink4") + .name("test_hlssink4") + .property("master-playlist-location", "/tmp/hlssink/master.m3u8") + .property("target-duration", 2u32) + .property("playlist-length", 2u32) + .property("max-files", 2u32) + .build() + .expect("Must be able to instantiate hlssink4"); + + hlssink4.set_property("playlist-type", HlsSink4PlaylistType::Event); + let pl_type: HlsSink4PlaylistType = hlssink4.property("playlist-type"); + assert_eq!(pl_type, HlsSink4PlaylistType::Event); + + hlssink4.set_property_from_str("playlist-type", "unspecified"); + + let muxer_type: HlsSink4MuxerType = hlssink4.property("muxer-type"); + assert_eq!(muxer_type, HlsSink4MuxerType::Cmaf); + + pipeline.add(&hlssink4).unwrap(); + + let (hls_events_sender, hls_events_receiver) = mpsc::sync_channel(100); + let master_playlist_content = Arc::new(Mutex::new(String::from(""))); + let playlist_content = Arc::new(Mutex::new(String::from(""))); + + setup_signals( + &hlssink4, + hls_events_sender.clone(), + master_playlist_content.clone(), + playlist_content.clone(), + HlsSink4MuxerType::Cmaf, + ); + + let audio_bin1 = audio_bin(256000).unwrap(); + let audio_bin1_pad = audio_bin1.static_pad("src").unwrap(); + let audio1_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let r = gst::Structure::builder("audio1-rendition") + .field("media", "AUDIO") + .field("uri", "hi-audio/audio.m3u8") + .field("group_id", "aac") + .field("language", "en") + .field("name", "English") + .field("default", true) + .field("autoselect", false) + .build(); + audio1_pad.set_property("alternate-rendition", r); + pipeline.add(&audio_bin1).unwrap(); + audio_bin1_pad.link(&audio1_pad).unwrap(); + + let audio_bin2 = audio_bin(128000).unwrap(); + let audio_bin2_pad = audio_bin2.static_pad("src").unwrap(); + let audio2_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let r = gst::Structure::builder("audio2-rendition") + .field("media", "AUDIO") + .field("uri", "mid-audio/audio.m3u8") + .field("group_id", "aac") + .field("language", "fr") + .field("name", "French") + .field("default", false) + .field("autoselect", false) + .build(); + audio2_pad.set_property("alternate-rendition", r); + pipeline.add(&audio_bin2).unwrap(); + audio_bin2_pad.link(&audio2_pad).unwrap(); + + let video_bin1 = video_bin(1920, 1080, 30, 2500, false).unwrap(); + let video_bin1_pad = video_bin1.static_pad("src").unwrap(); + let video1_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video1-variant") + .field("uri", "hi/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 2500) + .build(); + video1_pad.set_property("variant", v); + pipeline.add(&video_bin1).unwrap(); + video_bin1_pad.link(&video1_pad).unwrap(); + + pipeline.set_state(gst::State::Playing).unwrap(); + + let mut eos = false; + let bus = pipeline.bus().unwrap(); + while let Some(msg) = bus.timed_pop(gst::ClockTime::NONE) { + use gst::MessageView; + match msg.view() { + MessageView::Eos(..) => { + eos = true; + break; + } + MessageView::Error(e) => gst::error!(CAT, "hlssink4 error: {}", e), + _ => (), + } + } + + pipeline.debug_to_dot_file_with_ts( + gst::DebugGraphDetails::all(), + "multiple_audio_rendition_single_video_variant", + ); + + pipeline.set_state(gst::State::Null).unwrap(); + assert!(eos); + + let mut actual_events = Vec::new(); + while let Ok(event) = hls_events_receiver.recv_timeout(Duration::from_millis(1)) { + actual_events.push(event); + } + let expected_events = { + use self::HlsSinkEvent::*; + vec![ + GetMasterPlaylistStream("/tmp/hlssink/master.m3u8".to_string()), + GetInitStream("/tmp/hlssink/hi/init00000.mp4".to_string()), + GetInitStream("/tmp/hlssink/hi-audio/init00000.mp4".to_string()), + GetInitStream("/tmp/hlssink/mid-audio/init00000.mp4".to_string()), + GetPlaylistStream("/tmp/hlssink/hi/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/hi-audio/audio.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/mid-audio/audio.m3u8".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00001.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/hi-audio/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/hi-audio/segment00001.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/mid-audio/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/mid-audio/segment00001.m4s".to_string()), + ] + }; + assert!(is_playlist_events_eq(&expected_events, &actual_events)); + + let contents = master_playlist_content.lock().unwrap(); + + #[rustfmt::skip] + assert_eq!( + r###"#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-MEDIA:TYPE=AUDIO,URI="hi-audio/audio.m3u8",GROUP-ID="aac",LANGUAGE="en",NAME="English",DEFAULT=YES +#EXT-X-MEDIA:TYPE=AUDIO,URI="mid-audio/audio.m3u8",GROUP-ID="aac",LANGUAGE="fr",NAME="French" +#EXT-X-STREAM-INF:BANDWIDTH=2500,CODECS="avc1.640028,mp4a.40.2",AUDIO="aac" +hi/video.m3u8 +"###, + contents.to_string() + ); + + Ok(()) +} + +#[test] +fn hlssink4_single_audio_rendition_multiple_video_variant() -> Result<(), ()> { + init(); + + let pipeline = gst::Pipeline::with_name("hlssink4_pipeline"); + + let hlssink4 = gst::ElementFactory::make("hlssink4") + .name("test_hlssink4") + .property("master-playlist-location", "/tmp/hlssink/master.m3u8") + .property("target-duration", 2u32) + .property("playlist-length", 2u32) + .property("max-files", 2u32) + .build() + .expect("Must be able to instantiate hlssink4"); + + hlssink4.set_property("playlist-type", HlsSink4PlaylistType::Event); + let pl_type: HlsSink4PlaylistType = hlssink4.property("playlist-type"); + assert_eq!(pl_type, HlsSink4PlaylistType::Event); + + hlssink4.set_property_from_str("playlist-type", "unspecified"); + + let muxer_type: HlsSink4MuxerType = hlssink4.property("muxer-type"); + assert_eq!(muxer_type, HlsSink4MuxerType::Cmaf); + + pipeline.add(&hlssink4).unwrap(); + + let (hls_events_sender, hls_events_receiver) = mpsc::sync_channel(100); + let master_playlist_content = Arc::new(Mutex::new(String::from(""))); + let playlist_content = Arc::new(Mutex::new(String::from(""))); + + setup_signals( + &hlssink4, + hls_events_sender.clone(), + master_playlist_content.clone(), + playlist_content.clone(), + HlsSink4MuxerType::Cmaf, + ); + + let audio_bin1 = audio_bin(256000).unwrap(); + let audio_bin1_pad = audio_bin1.static_pad("src").unwrap(); + let audio1_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let r = gst::Structure::builder("audio1-rendition") + .field("media", "AUDIO") + .field("uri", "hi-audio/audio.m3u8") + .field("group_id", "aac") + .field("language", "en") + .field("name", "English") + .field("default", true) + .field("autoselect", false) + .build(); + audio1_pad.set_property("alternate-rendition", r); + pipeline.add(&audio_bin1).unwrap(); + audio_bin1_pad.link(&audio1_pad).unwrap(); + + let video_bin1 = video_bin(1920, 1080, 30, 2500, false).unwrap(); + let video_bin1_pad = video_bin1.static_pad("src").unwrap(); + let video1_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video1-variant") + .field("uri", "hi/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 2500) + .build(); + video1_pad.set_property("variant", v); + pipeline.add(&video_bin1).unwrap(); + video_bin1_pad.link(&video1_pad).unwrap(); + + let video_bin2 = video_bin(1280, 720, 30, 1500, false).unwrap(); + let video_bin2_pad = video_bin2.static_pad("src").unwrap(); + let video2_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video2-variant") + .field("uri", "mid/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 1500) + .build(); + video2_pad.set_property("variant", v); + pipeline.add(&video_bin2).unwrap(); + video_bin2_pad.link(&video2_pad).unwrap(); + + let video_bin3 = video_bin(640, 360, 24, 700, false).unwrap(); + let video_bin3_pad = video_bin3.static_pad("src").unwrap(); + let video3_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video2-variant") + .field("uri", "low/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 700) + .build(); + video3_pad.set_property("variant", v); + pipeline.add(&video_bin3).unwrap(); + video_bin3_pad.link(&video3_pad).unwrap(); + + pipeline.set_state(gst::State::Playing).unwrap(); + + let mut eos = false; + let bus = pipeline.bus().unwrap(); + while let Some(msg) = bus.timed_pop(gst::ClockTime::NONE) { + use gst::MessageView; + match msg.view() { + MessageView::Eos(..) => { + eos = true; + break; + } + MessageView::Error(e) => gst::error!(CAT, "hlssink4 error: {}", e), + _ => (), + } + } + + pipeline.debug_to_dot_file_with_ts( + gst::DebugGraphDetails::all(), + "single_audio_rendition_multiple_video_variant", + ); + + pipeline.set_state(gst::State::Null).unwrap(); + assert!(eos); + + let mut actual_events = Vec::new(); + while let Ok(event) = hls_events_receiver.recv_timeout(Duration::from_millis(1)) { + actual_events.push(event); + } + let expected_events = { + use self::HlsSinkEvent::*; + vec![ + GetMasterPlaylistStream("/tmp/hlssink/master.m3u8".to_string()), + GetInitStream("/tmp/hlssink/hi/init00000.mp4".to_string()), + GetInitStream("/tmp/hlssink/mid/init00000.mp4".to_string()), + GetInitStream("/tmp/hlssink/low/init00000.mp4".to_string()), + GetInitStream("/tmp/hlssink/hi-audio/init00000.mp4".to_string()), + GetPlaylistStream("/tmp/hlssink/hi/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/mid/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/low/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/hi-audio/audio.m3u8".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00001.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/mid/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/mid/segment00001.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/low/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/low/segment00001.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/hi-audio/segment00000.m4s".to_string()), + GetFragmentStream("/tmp/hlssink/hi-audio/segment00001.m4s".to_string()), + ] + }; + assert!(is_playlist_events_eq(&expected_events, &actual_events)); + + let contents = master_playlist_content.lock().unwrap(); + + #[rustfmt::skip] + assert_eq!( + r###"#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-MEDIA:TYPE=AUDIO,URI="hi-audio/audio.m3u8",GROUP-ID="aac",LANGUAGE="en",NAME="English",DEFAULT=YES +#EXT-X-STREAM-INF:BANDWIDTH=2500,CODECS="avc1.64001E,avc1.64001F,avc1.640028,mp4a.40.2",AUDIO="aac" +hi/video.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=1500,CODECS="avc1.64001E,avc1.64001F,avc1.640028,mp4a.40.2",AUDIO="aac" +mid/video.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=700,CODECS="avc1.64001E,avc1.64001F,avc1.640028,mp4a.40.2",AUDIO="aac" +low/video.m3u8 +"###, + contents.to_string() + ); + + Ok(()) +} + +#[test] +fn hlssink4_multiple_audio_rendition_multiple_video_variant_with_mpegts() -> Result<(), ()> { + init(); + + let pipeline = gst::Pipeline::with_name("hlssink4_pipeline"); + + let hlssink4 = gst::ElementFactory::make("hlssink4") + .name("test_hlssink4") + .property("master-playlist-location", "/tmp/hlssink/master.m3u8") + .property("target-duration", 2u32) + .property("playlist-length", 2u32) + .property("max-files", 2u32) + .build() + .expect("Must be able to instantiate hlssink4"); + + hlssink4.set_property("muxer-type", HlsSink4MuxerType::MpegTs); + let muxer_type: HlsSink4MuxerType = hlssink4.property("muxer-type"); + assert_eq!(muxer_type, HlsSink4MuxerType::MpegTs); + + pipeline.add(&hlssink4).unwrap(); + + let (hls_events_sender, hls_events_receiver) = mpsc::sync_channel(100); + let master_playlist_content = Arc::new(Mutex::new(String::from(""))); + let playlist_content = Arc::new(Mutex::new(String::from(""))); + + setup_signals( + &hlssink4, + hls_events_sender.clone(), + master_playlist_content.clone(), + playlist_content.clone(), + HlsSink4MuxerType::MpegTs, + ); + + let audio_bin1 = audio_bin(256000).unwrap(); + let audio_bin1_pad = audio_bin1.static_pad("src").unwrap(); + let audio1_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let r = gst::Structure::builder("audio1-rendition") + .field("media", "AUDIO") + .field("uri", "hi-audio/audio.m3u8") + .field("group_id", "aac") + .field("language", "en") + .field("name", "English") + .field("default", true) + .field("autoselect", false) + .build(); + audio1_pad.set_property("alternate-rendition", r); + pipeline.add(&audio_bin1).unwrap(); + audio_bin1_pad.link(&audio1_pad).unwrap(); + + let audio_bin2 = audio_bin(128000).unwrap(); + let audio_bin2_pad = audio_bin2.static_pad("src").unwrap(); + let audio2_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let r = gst::Structure::builder("audio2-rendition") + .field("media", "AUDIO") + .field("uri", "mid-audio/audio.m3u8") + .field("group_id", "aac") + .field("language", "fr") + .field("name", "French") + .field("default", false) + .field("autoselect", false) + .build(); + audio2_pad.set_property("alternate-rendition", r); + pipeline.add(&audio_bin2).unwrap(); + audio_bin2_pad.link(&audio2_pad).unwrap(); + + let video_bin1 = video_bin(1920, 1080, 30, 2500, false).unwrap(); + let video_bin1_pad = video_bin1.static_pad("src").unwrap(); + let video1_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video1-variant") + .field("uri", "hi/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 2500) + .build(); + video1_pad.set_property("variant", v); + pipeline.add(&video_bin1).unwrap(); + video_bin1_pad.link(&video1_pad).unwrap(); + + let video_bin2 = video_bin(1280, 720, 30, 1500, false).unwrap(); + let video_bin2_pad = video_bin2.static_pad("src").unwrap(); + let video2_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video2-variant") + .field("uri", "mid/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 1500) + .build(); + video2_pad.set_property("variant", v); + pipeline.add(&video_bin2).unwrap(); + video_bin2_pad.link(&video2_pad).unwrap(); + + let video_bin3 = video_bin(640, 360, 24, 700, false).unwrap(); + let video_bin3_pad = video_bin3.static_pad("src").unwrap(); + let video3_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video3-variant") + .field("uri", "low/video.m3u8") + .field("audio", "aac") + .field("bandwidth", 700) + .build(); + video3_pad.set_property("variant", v); + pipeline.add(&video_bin3).unwrap(); + video_bin3_pad.link(&video3_pad).unwrap(); + + pipeline.set_state(gst::State::Playing).unwrap(); + + let mut eos = false; + let bus = pipeline.bus().unwrap(); + while let Some(msg) = bus.timed_pop(gst::ClockTime::NONE) { + use gst::MessageView; + match msg.view() { + MessageView::Eos(..) => { + eos = true; + break; + } + MessageView::Error(e) => gst::error!(CAT, "hlssink4 error: {}", e), + _ => (), + } + } + + pipeline.debug_to_dot_file_with_ts( + gst::DebugGraphDetails::all(), + "multiple_audio_rendition_multiple_video_variant_with_mpegts", + ); + + pipeline.set_state(gst::State::Null).unwrap(); + assert!(eos); + + let mut actual_events = Vec::new(); + while let Ok(event) = hls_events_receiver.recv_timeout(Duration::from_millis(1)) { + actual_events.push(event); + } + let expected_events = { + use self::HlsSinkEvent::*; + vec![ + GetMasterPlaylistStream("/tmp/hlssink/master.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/hi/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/mid/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/low/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/hi-audio/audio.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/mid-audio/audio.m3u8".to_string()), + GetFragmentStream("/tmp/hlssink/hi-audio/segment00000.ts".to_string()), + GetFragmentStream("/tmp/hlssink/mid-audio/segment00000.ts".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00000.ts".to_string()), + GetFragmentStream("/tmp/hlssink/mid/segment00000.ts".to_string()), + GetFragmentStream("/tmp/hlssink/low/segment00000.ts".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00001.ts".to_string()), + GetFragmentStream("/tmp/hlssink/mid/segment00001.ts".to_string()), + GetFragmentStream("/tmp/hlssink/low/segment00001.ts".to_string()), + ] + }; + assert!(is_playlist_events_eq(&expected_events, &actual_events)); + + let contents = master_playlist_content.lock().unwrap(); + + #[rustfmt::skip] + assert_eq!( + r###"#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-MEDIA:TYPE=AUDIO,URI="hi-audio/audio.m3u8",GROUP-ID="aac",LANGUAGE="en",NAME="English",DEFAULT=YES +#EXT-X-MEDIA:TYPE=AUDIO,URI="mid-audio/audio.m3u8",GROUP-ID="aac",LANGUAGE="fr",NAME="French" +#EXT-X-STREAM-INF:BANDWIDTH=2500,CODECS="avc1,mp4a.40.2",AUDIO="aac" +hi/video.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=1500,CODECS="avc1,mp4a.40.2",AUDIO="aac" +mid/video.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=700,CODECS="avc1,mp4a.40.2",AUDIO="aac" +low/video.m3u8 +"###, + contents.to_string() + ); + + Ok(()) +} + +#[test] +fn hlssink4_multiple_video_variant_with_mpegts_audio_video_muxed() -> Result<(), ()> { + init(); + + let pipeline = gst::Pipeline::with_name("hlssink4_pipeline"); + + let hlssink4 = gst::ElementFactory::make("hlssink4") + .name("test_hlssink4") + .property("master-playlist-location", "/tmp/hlssink/master.m3u8") + .property("target-duration", 2u32) + .property("playlist-length", 2u32) + .property("max-files", 2u32) + .build() + .expect("Must be able to instantiate hlssink4"); + + hlssink4.set_property("muxer-type", HlsSink4MuxerType::MpegTs); + let muxer_type: HlsSink4MuxerType = hlssink4.property("muxer-type"); + assert_eq!(muxer_type, HlsSink4MuxerType::MpegTs); + + pipeline.add(&hlssink4).unwrap(); + + let (hls_events_sender, hls_events_receiver) = mpsc::sync_channel(20); + let master_playlist_content = Arc::new(Mutex::new(String::from(""))); + let playlist_content = Arc::new(Mutex::new(String::from(""))); + + setup_signals( + &hlssink4, + hls_events_sender.clone(), + master_playlist_content.clone(), + playlist_content.clone(), + HlsSink4MuxerType::MpegTs, + ); + + let video_bin1 = video_bin(1920, 1080, 30, 2500, false).unwrap(); + let video_bin1_pad = video_bin1.static_pad("src").unwrap(); + let video1_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video1-variant") + .field("uri", "hi/video.m3u8") + .field("bandwidth", 2500) + .field("codecs", "avc1,mp4a.40.2") + .build(); + video1_pad.set_property("variant", v); + pipeline.add(&video_bin1).unwrap(); + video_bin1_pad.link(&video1_pad).unwrap(); + + let video_bin2 = video_bin(1280, 720, 30, 1500, false).unwrap(); + let video_bin2_pad = video_bin2.static_pad("src").unwrap(); + let video2_pad = hlssink4.request_pad_simple("video_%u").unwrap(); + let v = gst::Structure::builder("video2-variant") + .field("uri", "mid/video.m3u8") + .field("bandwidth", 1500) + .field("codecs", "avc1,mp4a.40.2") + .build(); + video2_pad.set_property("variant", v); + pipeline.add(&video_bin2).unwrap(); + video_bin2_pad.link(&video2_pad).unwrap(); + + /* + * Note that the next two audio variants have the same URI as + * the above two video variants. In the MPEG-TS case, this will + * result in them being muxed. + */ + let audio_bin1 = audio_bin(256000).unwrap(); + let audio_bin1_pad = audio_bin1.static_pad("src").unwrap(); + let audio1_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let v = gst::Structure::builder("audio1-variant") + .field("uri", "hi/video.m3u8") + .field("bandwidth", 256000) + .field("codecs", "avc1,mp4a.40.2") + .build(); + audio1_pad.set_property("variant", v); + pipeline.add(&audio_bin1).unwrap(); + audio_bin1_pad.link(&audio1_pad).unwrap(); + + let audio_bin2 = audio_bin(128000).unwrap(); + let audio_bin2_pad = audio_bin2.static_pad("src").unwrap(); + let audio2_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let v = gst::Structure::builder("audio2-variant") + .field("uri", "mid/video.m3u8") + .field("bandwidth", 128000) + .field("codecs", "avc1,mp4a.40.2") + .build(); + audio2_pad.set_property("variant", v); + pipeline.add(&audio_bin2).unwrap(); + audio_bin2_pad.link(&audio2_pad).unwrap(); + + /* Audio only variant, not muxed with video */ + let audio_bin3 = audio_bin(64000).unwrap(); + let audio_bin3_pad = audio_bin3.static_pad("src").unwrap(); + let audio3_pad = hlssink4.request_pad_simple("audio_%u").unwrap(); + let v = gst::Structure::builder("audio3-variant") + .field("uri", "low-audio/audio-only.m3u8") + .field("bandwidth", 64000) + .field("codecs", "mp4a.40.2") + .build(); + audio3_pad.set_property("variant", v); + pipeline.add(&audio_bin3).unwrap(); + audio_bin3_pad.link(&audio3_pad).unwrap(); + + pipeline.set_state(gst::State::Playing).unwrap(); + + let mut eos = false; + let bus = pipeline.bus().unwrap(); + while let Some(msg) = bus.timed_pop(gst::ClockTime::NONE) { + use gst::MessageView; + match msg.view() { + MessageView::Eos(..) => { + eos = true; + break; + } + MessageView::Error(e) => gst::error!(CAT, "hlssink4 error: {}", e), + _ => (), + } + } + + pipeline.debug_to_dot_file_with_ts( + gst::DebugGraphDetails::all(), + "multiple_video_variant_with_mpegts_audio_video_muxed", + ); + + pipeline.set_state(gst::State::Null).unwrap(); + assert!(eos); + + let mut actual_events = Vec::new(); + while let Ok(event) = hls_events_receiver.recv_timeout(Duration::from_millis(1)) { + actual_events.push(event); + } + let expected_events = { + use self::HlsSinkEvent::*; + vec![ + GetMasterPlaylistStream("/tmp/hlssink/master.m3u8".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00000.ts".to_string()), + GetFragmentStream("/tmp/hlssink/mid/segment00000.ts".to_string()), + GetFragmentStream("/tmp/hlssink/mid/segment00001.ts".to_string()), + GetFragmentStream("/tmp/hlssink/hi/segment00001.ts".to_string()), + GetFragmentStream("/tmp/hlssink/low-audio/segment00000.ts".to_string()), + GetPlaylistStream("/tmp/hlssink/hi/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/mid/video.m3u8".to_string()), + GetPlaylistStream("/tmp/hlssink/low-audio/audio-only.m3u8".to_string()), + ] + }; + assert!(is_playlist_events_eq(&expected_events, &actual_events)); + + let contents = master_playlist_content.lock().unwrap(); + + #[rustfmt::skip] + assert_eq!( + r###"#EXTM3U +#EXT-X-VERSION:4 +#EXT-X-STREAM-INF:BANDWIDTH=2500,CODECS="avc1,mp4a.40.2" +hi/video.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=1500,CODECS="avc1,mp4a.40.2" +mid/video.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS="mp4a.40.2" +low-audio/audio-only.m3u8 +"###, + contents.to_string() + ); + + Ok(()) +}