Initial commit

main
Charlotte Som 2023-03-10 06:33:01 +00:00
commit eeaf4aa681
21 changed files with 4101 additions and 0 deletions

60
CMakeLists.txt Normal file
View File

@ -0,0 +1,60 @@
project(char-rtc-obs)
find_package(Corrosion QUIET)
if(NOT Corrosion_FOUND)
include(FetchContent)
FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG v0.3.1)
FetchContent_MakeAvailable(Corrosion)
endif()
set(CHAR_RTC_GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated")
corrosion_import_crate(MANIFEST_PATH rtc-backend/Cargo.toml)
corrosion_set_env_vars(
char-rtc-backend "CHAR_RTC_GENERATED_DIR=${CHAR_RTC_GENERATED_DIR}"
"CHAR_RTC_OBS_VERSION=${OBS_VERSION_CANONICAL}")
# Force dependent crates to link against the correct deployment target
if(OS_MACOS)
corrosion_set_env_vars(
char-rtc-backend
"CFLAGS_aarch64_apple_darwin=-mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}"
"CFLAGS_x86_64_apple_darwin=-mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}"
)
corrosion_add_target_rustflags(
char-rtc-backend
-Clink-arg=-mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET})
endif()
add_library(char-rtc-obs MODULE)
target_sources(char-rtc-obs PRIVATE char-rtc.c whip-service.c whip-service.h whip-output.c whip-output.h
"${CHAR_RTC_GENERATED_DIR}/bindings.h")
set_source_files_properties("${CHAR_RTC_GENERATED_DIR}/bindings.h"
PROPERTIES GENERATED TRUE)
target_link_libraries(char-rtc-obs OBS::libobs char-rtc-backend)
target_include_directories(char-rtc-obs PRIVATE ${CHAR_RTC_GENERATED_DIR})
if(OS_WINDOWS)
set(MODULE_DESCRIPTION "live.umm.gay module")
configure_file("${CMAKE_SOURCE_DIR}/cmake/bundle/windows/obs-module.rc.in"
char-rtc-obs.rc)
target_sources(char-rtc-obs PRIVATE char-rtc-obs.rc)
endif()
if(OS_MACOS)
find_library(COREFOUNDATION CoreFoundation)
find_library(SECURITY_FRAMEWORK Security)
find_library(SYSTEMCONFIGURATION SystemConfiguration)
mark_as_advanced(COREFOUNDATION SECURITY_FRAMEWOR SYSTEMCONFIGURATION)
target_link_libraries(char-rtc-obs ${COREFOUNDATION} ${SECURITY_FRAMEWORK}
${SYSTEMCONFIGURATION})
endif()
set_target_properties(char-rtc-obs PROPERTIES FOLDER "plugins")
setup_plugin_target(char-rtc-obs data)

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# char-rtc-obs
OBS plugin to stream via WebRTC (using `webrtc-rs`) to `live.umm.gay`
you clone this into your `obs-studio/plugins/` folder :)

21
char-rtc.c Normal file
View File

@ -0,0 +1,21 @@
#include <obs-module.h>
#include "whip-output.h"
#include "whip-service.h"
OBS_DECLARE_MODULE()
OBS_MODULE_USE_DEFAULT_LOCALE("char-rtc-obs", "en-US")
MODULE_EXPORT const char *obs_module_description(void)
{
return obs_module_text("Module.Description");
}
MODULE_EXPORT bool obs_module_load()
{
char_rtc_install_logger();
register_whip_output();
register_whip_service();
return true;
}

0
data/.keepme Normal file
View File

4
data/locale/en-US.ini Normal file
View File

@ -0,0 +1,4 @@
Module.Description="Publish to live.umm.gay using OBS Studio"
Output.Name="live.umm.gay Output"
Service.Name="live.umm.gay Service"
Service.BearerToken="Bearer Token"

1
rtc-backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2736
rtc-backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
rtc-backend/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "char-rtc-backend"
version = "0.1.0"
edition = "2021"
build = "build.rs"
[dependencies]
webrtc = "0.6.0"
webrtc-mdns = "0.5.2"
tokio = "1.16.1"
anyhow = "1.0.45"
bytes = "1"
serde = { version = "1.0", features = ["derive"] }
base64 = "0.13.0"
reqwest = { version = "0.11.7", default-features = false, features = ["json", "rustls-tls"] }
log = { version = "0.4.17", features = ["std"] }
link_args = "0.6.0"
env_logger = { version = "0.10.0", default-features = false }
if-addrs = "0.7.0"
[lib]
crate-type=["staticlib"]
[build-dependencies]
cbindgen = "0.24.3"

26
rtc-backend/build.rs Normal file
View File

@ -0,0 +1,26 @@
use std::{env, path::PathBuf};
fn main() {
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let bindings_dir = env::var("CHAR_RTC_GENERATED_DIR").ok();
let version = env::var("CHAR_RTC_OBS_VERSION").unwrap_or_else(|_| "0.0.0".into());
// Make libobs version available
println!("cargo:rustc-env=OBS_VERSION={version}");
let config = cbindgen::Config {
language: cbindgen::Language::C,
..Default::default()
};
match cbindgen::generate_with_config(crate_dir, config) {
Ok(bindings) => {
if let Some(bindings_dir) = bindings_dir {
let bindings_dir: PathBuf = bindings_dir.into();
bindings.write_to_file(bindings_dir.join("bindings.h"));
}
}
Err(cbindgen::Error::ParseSyntaxError { .. }) => (), // ignore in favor of cargo's syntax check
Err(err) => panic!("{:?}", err),
};
}

View File

@ -0,0 +1 @@
language = "C"

View File

@ -0,0 +1,2 @@
mod obs_log;
mod output;

View File

@ -0,0 +1,7 @@
use crate::obs_log::OBSLogger;
#[no_mangle]
pub extern "C" fn char_rtc_install_logger() {
OBSLogger::install();
log::info!("webrtc plugin preamble initialized")
}

View File

@ -0,0 +1,174 @@
use crate::output::{EncodedPacket, EncodedPacketType, OutputStream, OutputStreamError};
use anyhow::Result;
use log::{error, info};
use std::{
os::raw::{c_char, c_void},
slice,
time::Duration,
};
use tokio::runtime::Runtime;
pub struct RTCOutput {
stream: OutputStream,
runtime: Runtime,
}
/// cbindgen:prefix-with-name
#[repr(C)]
pub enum RTCOutputError {
ConnectFailed,
NetworkError,
}
impl From<OutputStreamError> for RTCOutputError {
fn from(ose: OutputStreamError) -> Self {
match ose {
OutputStreamError::ConnectFailed => RTCOutputError::ConnectFailed,
OutputStreamError::NetworkError => RTCOutputError::NetworkError,
OutputStreamError::WriteError(_) => RTCOutputError::NetworkError,
}
}
}
// You must call `char_rtc_output_free` on the returned value
#[no_mangle]
pub extern "C" fn char_rtc_output_new() -> *mut RTCOutput {
(|| -> Result<*mut RTCOutput> {
let runtime = tokio::runtime::Runtime::new()?;
let stream = runtime.block_on(OutputStream::new())?;
Ok(Box::into_raw(Box::new(RTCOutput { stream, runtime })))
})()
.unwrap_or_else(|e| {
error!("Unable to create whip output: {e:?}");
std::ptr::null_mut::<RTCOutput>()
})
}
#[no_mangle]
pub unsafe extern "C" fn char_rtc_output_free(output: *mut RTCOutput) {
info!("Freeing whip output");
if !output.is_null() {
drop(Box::from_raw(output));
}
}
#[no_mangle]
pub unsafe extern "C" fn char_rtc_output_bytes_sent(output: *const RTCOutput) -> u64 {
let output = output.as_ref().unwrap();
output.stream.bytes_sent()
}
#[no_mangle]
pub unsafe extern "C" fn char_rtc_output_dropped_frames(output: *const RTCOutput) -> i32 {
let output = output.as_ref().unwrap();
output.stream.dropped_frames()
}
#[no_mangle]
pub unsafe extern "C" fn char_rtc_output_congestion(output: *const RTCOutput) -> f64 {
let output = output.as_ref().unwrap();
output.stream.congestion()
}
#[no_mangle]
pub unsafe extern "C" fn char_rtc_output_connect_time_ms(output: *const RTCOutput) -> i32 {
let output = output.as_ref().unwrap();
output.stream.connect_time().as_millis() as i32
}
#[no_mangle]
pub unsafe extern "C" fn char_rtc_output_connect(
output: *const RTCOutput,
url: *const c_char,
bearer_token: *const c_char,
) {
let output = output.as_ref().unwrap();
let url = std::ffi::CStr::from_ptr(url).to_str().unwrap().to_owned();
let bearer_token = if !bearer_token.is_null() {
Some(
std::ffi::CStr::from_ptr(bearer_token)
.to_str()
.unwrap()
.to_owned(),
)
} else {
None
};
output.runtime.spawn(async move {
output.stream.connect(&url, bearer_token.as_deref()).await;
});
}
// Once closed, you cannot call `char_rtc_output_connect` again
#[no_mangle]
pub unsafe extern "C" fn char_rtc_output_close(output: *const RTCOutput) {
let output = output.as_ref().unwrap();
info!("Closing whip output");
output
.runtime
.block_on(async {
info!("I'm on a thread!");
output.stream.close().await
})
.unwrap_or_else(|e| error!("Failed closing whip output: {e:?}"))
}
/// Write an audio or video packet to the whip output
#[no_mangle]
pub unsafe extern "C" fn char_rtc_output_write(
output: *const RTCOutput,
data: *const u8,
size: usize,
duration: u64,
is_audio: bool,
) -> bool {
let output = output.as_ref().unwrap();
let slice: &[u8] = slice::from_raw_parts(data, size);
let encoded_packet = EncodedPacket {
data: slice.to_owned(),
duration: Duration::from_micros(duration),
typ: if is_audio {
EncodedPacketType::Audio
} else {
EncodedPacketType::Video
},
};
output
.stream
.write(encoded_packet)
.map(|_| true)
.unwrap_or_else(|e| {
error!("Failed to write packets to whip output: {e:?}");
false
})
}
pub struct ErrorCallbackUserdata(*mut c_void);
unsafe impl Send for ErrorCallbackUserdata {}
unsafe impl Sync for ErrorCallbackUserdata {}
impl ErrorCallbackUserdata {
fn as_ptr(&self) -> *mut c_void {
self.0
}
}
type RTCOutputErrorCallback = unsafe extern "C" fn(user_data: *mut c_void, error: RTCOutputError);
#[no_mangle]
pub unsafe extern "C" fn char_rtc_output_set_error_callback(
output: *const RTCOutput,
cb: RTCOutputErrorCallback,
user_data: *mut c_void,
) {
let output = output.as_ref().unwrap();
let user_data = ErrorCallbackUserdata(user_data);
output
.stream
.set_error_callback(Box::new(move |error| cb(user_data.as_ptr(), error.into())))
}

22
rtc-backend/src/lib.rs Normal file
View File

@ -0,0 +1,22 @@
mod ffi;
mod obs_log;
mod output;
mod whip;
// Manually configure the linked runtimes for windows due to rust defaulting to msvcrt for both debug and release
#[cfg(all(not(debug_assertions), target_env = "msvc"))]
link_args::windows! {
unsafe {
default_lib("kernel32.lib", "vcruntime.lib", "msvcrtd", "ntdll.lib", "iphlpapi.lib");
no_default_lib("msvcrtd", "vcruntimed.lib");
}
}
#[cfg(all(debug_assertions, target_env = "msvc"))]
link_args::windows! {
unsafe {
default_lib("kernel32.lib", "vcruntimed.lib", "msvcrtd", "ntdll.lib", "iphlpapi.lib");
no_default_lib("msvcrt", "vcruntime.lib");
}
}

127
rtc-backend/src/obs_log.rs Normal file
View File

@ -0,0 +1,127 @@
use env_logger::filter::{Builder, Filter};
use log::{Level, Log};
use std::{ffi::CStr, sync::atomic::AtomicBool, sync::atomic::Ordering};
static DEBUG_WHIP: AtomicBool = AtomicBool::new(false);
pub(crate) fn debug_whip() -> bool {
DEBUG_WHIP.load(Ordering::Relaxed)
}
/// cbindgen:ignore
mod obs {
use std::os::raw::c_char;
use std::os::raw::c_int;
#[repr(C)]
#[derive(Debug)]
pub(super) struct obs_cmdline_args {
pub(super) argc: c_int,
pub(super) argv: *const *const c_char,
}
#[link(name = "libobs")]
extern "C" {
pub(super) fn blog(log_level: i32, format: *const c_char, ...);
pub(super) fn obs_get_cmdline_args() -> obs_cmdline_args;
}
}
pub struct OBSLogger {
log_filter: Filter,
}
impl OBSLogger {
pub fn install() {
// Pull our RUST_LOG in case they are using that
let mut log_filter: String = std::env::var("RUST_LOG").unwrap_or_else(|_| "INFO".into());
unsafe {
let raw_args = obs::obs_get_cmdline_args();
let mut args = std::slice::from_raw_parts(raw_args.argv, raw_args.argc as usize)
.iter()
.map(|arg| CStr::from_ptr(*arg).to_string_lossy());
while let Some(arg) = args.next() {
match arg.as_ref() {
"--debug-webrtc-whip" => {
DEBUG_WHIP.store(true, Ordering::Relaxed);
}
"--debug-webrtc-filter" => {
if let Some(filter) = args.next() {
log_filter = filter.into();
}
}
_ => {}
}
}
let mut filter_builder = Builder::new();
log::set_boxed_logger(Box::new(Self {
log_filter: filter_builder.parse(&log_filter).build(),
}))
.expect("Logger already set");
log::set_max_level(log::LevelFilter::Trace);
}
}
}
impl Log for OBSLogger {
fn enabled(&self, metadata: &log::Metadata) -> bool {
self.log_filter.enabled(metadata)
}
fn log(&self, record: &log::Record) {
if !self.log_filter.matches(record) {
return;
}
let level = record.level();
let log_level = match level {
Level::Trace => (300, "[T] "),
Level::Debug => (300, "[D] "),
Level::Info => (300, ""),
Level::Warn => (200, ""),
Level::Error => (100, ""),
};
let message_prefix = format!(
"{}[{}:{}]",
log_level.1,
if record.target().is_empty() {
record.module_path().unwrap_or("unk")
} else {
record.target()
},
record.line().unwrap_or(0),
);
const MAX_CHUNK_SIZE: usize = 3500;
let message_chars = record.args().to_string().chars().collect::<Vec<char>>();
let chunks = message_chars.chunks(MAX_CHUNK_SIZE);
let chunks_len = chunks.len();
for (index, chunk) in chunks.enumerate() {
unsafe {
let chunk = chunk.iter().cloned().collect::<String>();
if chunks_len == 1 {
obs::blog(
log_level.0,
format!("{message_prefix}: {chunk}\0").as_ptr() as *const i8,
);
} else {
obs::blog(
log_level.0,
format!(
"{message_prefix}: MULTIPART [{}/{chunks_len}] {chunk}\0",
index + 1,
)
.as_ptr() as *const i8,
);
}
}
}
}
fn flush(&self) {}
}

443
rtc-backend/src/output.rs Normal file
View File

@ -0,0 +1,443 @@
use crate::whip;
use anyhow::{anyhow, Result};
use bytes::Bytes;
use log::{debug, error, info, trace};
use reqwest::Url;
use std::{
boxed::Box,
sync::{Arc, Mutex, RwLock},
thread::JoinHandle,
time::{Duration, Instant},
};
use tokio::{
select,
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
time::interval,
};
use webrtc::{
api::{
interceptor_registry::register_default_interceptors,
media_engine::{MediaEngine, MIME_TYPE_H264, MIME_TYPE_OPUS},
setting_engine::SettingEngine,
APIBuilder,
},
ice_transport::ice_connection_state::RTCIceConnectionState,
interceptor::registry::Registry,
media::Sample,
peer_connection::{
configuration::RTCConfiguration, peer_connection_state::RTCPeerConnectionState,
RTCPeerConnection,
},
rtp_transceiver::{
rtp_codec::{RTCRtpCodecCapability, RTCRtpCodecParameters, RTPCodecType},
rtp_transceiver_direction::RTCRtpTransceiverDirection,
RTCPFeedback, RTCRtpTransceiverInit,
},
stats::StatsReportType,
track::track_local::track_local_static_sample::TrackLocalStaticSample,
};
pub type ErrorCallback = Box<dyn FnMut(OutputStreamError) + Send + Sync>;
#[derive(Debug)]
pub struct EncodedPacket {
pub data: Vec<u8>,
pub duration: Duration,
pub typ: EncodedPacketType,
}
#[derive(Debug)]
pub enum EncodedPacketType {
Audio,
Video,
}
/// Errors for [`OutputStream`] worker thread
#[derive(Debug)]
pub enum OutputStreamError {
ConnectFailed,
NetworkError,
WriteError(webrtc::Error),
}
#[derive(Debug)]
pub enum Message {
Packet(EncodedPacket),
Error(OutputStreamError),
Close,
}
pub enum WorkerResult {
Error(OutputStreamError),
Close,
}
#[derive(Default)]
struct OutputStreamStats {
bytes_sent: u64,
congestion: f64,
dropped_frames: i32,
connect_time: Option<Instant>,
connect_duration: Duration,
}
pub struct OutputStream {
video_track: Arc<TrackLocalStaticSample>,
audio_track: Arc<TrackLocalStaticSample>,
peer_connection: Arc<RTCPeerConnection>,
stats: Arc<RwLock<OutputStreamStats>>,
worker_tx: UnboundedSender<Message>,
worker_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
whip_resource: Arc<Mutex<Option<Url>>>,
error_callback: Arc<Mutex<Option<ErrorCallback>>>,
}
impl OutputStream {
fn start_worker(&mut self, mut worker_rx: UnboundedReceiver<Message>) {
let worker_handle = std::thread::spawn({
let audio_track = self.audio_track.clone();
let video_track = self.video_track.clone();
let peer_connection = self.peer_connection.clone();
let stats = self.stats.clone();
let error_callback = self.error_callback.clone();
move || {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name("webrtc_worker")
.worker_threads(4)
.build()
.unwrap();
let runtime = runtime;
let result = runtime.block_on(
async move {
let mut interval = interval(Duration::from_millis(500));
'worker_loop: loop {
let result = select! {
message = worker_rx.recv() => {
match message {
Some(Message::Packet(packet)) => {
let sample = Sample {
data: Bytes::from(packet.data),
duration: packet.duration,
..Default::default()
};
match (packet.typ, peer_connection.connection_state()) {
(_, RTCPeerConnectionState::Failed | RTCPeerConnectionState::Closed) => Err(WorkerResult::Error(OutputStreamError::NetworkError)),
(EncodedPacketType::Audio, _) => audio_track.write_sample(&sample).await.map_err(|e| WorkerResult::Error(OutputStreamError::WriteError(e))),
(EncodedPacketType::Video, _) => video_track.write_sample(&sample).await.map_err(|e| WorkerResult::Error(OutputStreamError::WriteError(e)))
}
},
Some(Message::Error(e)) => Err(WorkerResult::Error(e)),
Some(Message::Close) | None => Err(WorkerResult::Close),
}
}
_ = interval.tick() => {
let pc_stats = peer_connection.get_stats().await;
let stats = &mut stats.write().unwrap();
if let Some(StatsReportType::Transport(transport_stats)) = pc_stats.reports.get("ice_transport") {
stats.bytes_sent = transport_stats.bytes_sent as u64;
}
Ok(())
}
};
if let Err(e) = result {
break 'worker_loop e;
}
}
}
);
match result {
WorkerResult::Close => {}
WorkerResult::Error(e) => {
if let Some(callback) = &mut *error_callback.lock().unwrap() {
error!("Worker encountered error: {e:?}");
callback(e);
}
}
}
info!("Exiting worker thread");
}
});
*self.worker_handle.lock().unwrap() = Some(worker_handle);
}
pub async fn new() -> Result<Self> {
let video_track = Arc::new(TrackLocalStaticSample::new(
RTCRtpCodecCapability {
mime_type: MIME_TYPE_H264.to_owned(),
clock_rate: 90000,
sdp_fmtp_line: "profile-level-id=42e01f; max-fs=3600; max-mbps=108000; max-br=1400"
.to_string(),
..Default::default()
},
"video".to_owned(),
"webrtc-rs".to_owned(),
));
let audio_track = Arc::new(TrackLocalStaticSample::new(
RTCRtpCodecCapability {
mime_type: MIME_TYPE_OPUS.to_owned(),
..Default::default()
},
"audio".to_owned(),
"webrtc-rs".to_owned(),
));
let mut m = MediaEngine::default();
m.register_codec(
RTCRtpCodecParameters {
capability: RTCRtpCodecCapability {
mime_type: MIME_TYPE_OPUS.to_owned(),
clock_rate: 48000,
channels: 2,
sdp_fmtp_line: "minptime=10;useinbandfec=1".to_owned(),
rtcp_feedback: vec![],
},
payload_type: 111,
..Default::default()
},
RTPCodecType::Audio,
)?;
m.register_codec(
RTCRtpCodecParameters {
capability: RTCRtpCodecCapability {
mime_type: MIME_TYPE_H264.to_owned(),
clock_rate: 90000,
channels: 0,
sdp_fmtp_line:
"level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"
.to_owned(),
rtcp_feedback: vec![
RTCPFeedback {
typ: "goog-remb".to_owned(),
parameter: "".to_owned(),
},
RTCPFeedback {
typ: "ccm".to_owned(),
parameter: "fir".to_owned(),
},
RTCPFeedback {
typ: "nack".to_owned(),
parameter: "".to_owned(),
},
RTCPFeedback {
typ: "nack".to_owned(),
parameter: "pli".to_owned(),
},
],
},
payload_type: 125,
..Default::default()
},
RTPCodecType::Video,
)?;
let mut registry = Registry::new();
registry = register_default_interceptors(registry, &mut m)?;
let gather_ips: Vec<_> = if_addrs::get_if_addrs()
.unwrap_or_else(|_| Vec::new())
.into_iter()
.filter(|addr| {
if !addr.is_loopback() {
trace!(
"Valid gather address for interface {}: {:#?}",
addr.name,
addr.ip()
);
true
} else {
trace!(
"Loopback address ignored for interface {}: {:#?}",
addr.name,
addr.ip()
);
false
}
})
.map(|addr| addr.ip())
.collect();
let mut setting_engine = SettingEngine::default();
// Only attempt to limit if we found valid interfaces, otherwise do not attempt
// to limit as we may be on a platform that doesn't expose enough information
if !gather_ips.is_empty() {
debug!("Gather addresses: {gather_ips:#?}");
setting_engine.set_ip_filter(Box::new(move |ip: std::net::IpAddr| -> bool {
gather_ips.contains(&ip)
}));
}
let api = APIBuilder::new()
.with_media_engine(m)
.with_interceptor_registry(registry)
.with_setting_engine(setting_engine)
.build();
// Prepare the configuration
let config = RTCConfiguration {
..Default::default()
};
let peer_connection = Arc::new(api.new_peer_connection(config).await?);
let stats: Arc<RwLock<OutputStreamStats>> = Arc::new(RwLock::new(OutputStreamStats {
connect_time: None,
..Default::default()
}));
let (worker_tx, worker_rx): (UnboundedSender<Message>, UnboundedReceiver<Message>) =
mpsc::unbounded_channel();
let error_callback: Arc<Mutex<Option<ErrorCallback>>> = Arc::new(Mutex::new(None));
let mut output_stream = Self {
audio_track,
video_track,
peer_connection,
stats,
worker_tx,
worker_handle: Arc::new(Mutex::new(None)),
whip_resource: Arc::new(Mutex::new(None)),
error_callback,
};
output_stream.start_worker(worker_rx);
Ok(output_stream)
}
pub async fn connect(&self, url: &str, bearer_token: Option<&str>) {
self.connect_internal(url, bearer_token)
.await
.unwrap_or_else(|e| {
error!("Failed connecting to: {e}");
let _ = self
.worker_tx
.send(Message::Error(OutputStreamError::ConnectFailed));
})
}
async fn connect_internal(&self, url: &str, bearer_token: Option<&str>) -> Result<()> {
println!("Setting up webrtc!");
self.peer_connection
.add_transceiver_from_track(
self.video_track.clone(),
&[RTCRtpTransceiverInit {
direction: RTCRtpTransceiverDirection::Sendonly,
send_encodings: Vec::new(),
}],
)
.await?;
self.peer_connection
.add_transceiver_from_track(
self.audio_track.clone(),
&[RTCRtpTransceiverInit {
direction: RTCRtpTransceiverDirection::Sendonly,
send_encodings: Vec::new(),
}],
)
.await?;
self.peer_connection
.on_ice_connection_state_change(Box::new(
move |connection_state: RTCIceConnectionState| {
info!("Connection State has changed {}", connection_state);
if connection_state == RTCIceConnectionState::Connected {
// on_success();
}
Box::pin(async {})
},
));
self.peer_connection
.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
debug!("Peer Connection State has changed: {}", s);
if s == RTCPeerConnectionState::Failed {
error!("Peer connection state went to failed")
}
Box::pin(async {})
}));
let offer = self.peer_connection.create_offer(None).await?;
let mut gather_complete = self.peer_connection.gathering_complete_promise().await;
self.peer_connection.set_local_description(offer).await?;
// Block until gathering complete
let _ = gather_complete.recv().await;
let offer = self
.peer_connection
.local_description()
.await
.ok_or_else(|| anyhow!("No local description available"))?;
let (answer, whip_resource) = whip::offer(url, bearer_token, offer).await?;
self.peer_connection.set_remote_description(answer).await?;
*self.whip_resource.lock().unwrap() = Some(whip_resource);
Ok(())
}
pub async fn close(&self) -> Result<()> {
let close_result = self.worker_tx.send(Message::Close);
// Take worker handle so it's dropped
let worker_future = self.worker_handle.lock().unwrap().take();
// If close was a success (worker could receive messages), join on thread
// Otherwise, worker thread is already dead or currently closing due to error callback
if close_result.is_ok() {
if let Some(worker_future) = worker_future {
worker_future
.join()
.unwrap_or_else(|e| error!("Failed joining worker thread: {e:?}"));
}
}
let whip_resource = self.whip_resource.lock().unwrap().take();
if let Some(whip_resource) = whip_resource {
whip::delete(&whip_resource).await?;
}
Ok(self.peer_connection.close().await?)
}
pub fn bytes_sent(&self) -> u64 {
self.stats.read().unwrap().bytes_sent
}
pub fn congestion(&self) -> f64 {
self.stats.read().unwrap().congestion
}
pub fn connect_time(&self) -> Duration {
let mut stats = self.stats.write().unwrap();
if let Some(connect_time) = stats.connect_time {
stats.connect_duration = Instant::now() - connect_time;
}
stats.connect_duration
}
pub fn dropped_frames(&self) -> i32 {
self.stats.read().unwrap().dropped_frames
}
pub fn write(&self, packet: EncodedPacket) -> Result<()> {
self.worker_tx
.send(Message::Packet(packet))
.map_err(|e| e.into())
}
pub fn set_error_callback(&self, callback: ErrorCallback) {
*self.error_callback.lock().unwrap() = Some(callback);
}
}

93
rtc-backend/src/whip.rs Normal file
View File

@ -0,0 +1,93 @@
use anyhow::Result;
use log::{info, warn};
use reqwest::{
header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE, LOCATION, USER_AGENT},
Url,
};
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
use crate::obs_log;
const OBS_VERSION: &str = env!("OBS_VERSION");
pub async fn offer(
url: &str,
bearer_token: Option<&str>,
local_desc: RTCSessionDescription,
) -> Result<(RTCSessionDescription, Url)> {
let client = reqwest::Client::new();
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/sdp"));
headers.insert(
USER_AGENT,
HeaderValue::from_str(&format!("libobs/{OBS_VERSION}"))?,
);
if let Some(bearer_token) = bearer_token {
if !bearer_token.is_empty() {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {bearer_token}"))?,
);
}
}
if obs_log::debug_whip() {
info!(
"[WHIP DEBUG | CAUTION SENSITIVE INFO] Sending offer to {url}: {}",
local_desc.sdp
);
}
let request = client.post(url).headers(headers).body(local_desc.sdp);
if obs_log::debug_whip() {
info!("[WHIP DEBUG | CAUTION SENSITIVE INFO] Offer request {request:#?}");
}
let response = request.send().await?;
if obs_log::debug_whip() {
info!("[WHIP DEBUG | CAUTION SENSITIVE INFO] Offer response: {response:#?}");
}
let mut url = response.url().to_owned();
if let Some(location) = response.headers().get(LOCATION) {
url.set_path(location.to_str()?);
}
let body = response.text().await?;
let sdp = RTCSessionDescription::answer(body)?;
if obs_log::debug_whip() {
info!("[WHIP DEBUG | CAUTION SENSITIVE INFO] Answer SDP: {sdp:#?}");
}
Ok((sdp, url))
}
pub async fn delete(url: &Url) -> Result<()> {
let client = reqwest::Client::new();
let request = client.delete(url.to_owned()).header(
USER_AGENT,
HeaderValue::from_str(&format!("libobs/{OBS_VERSION}"))?,
);
if obs_log::debug_whip() {
info!("[WHIP DEBUG | CAUTION SENSITIVE INFO] Delete request {request:#?}");
}
let response = request.send().await?;
if obs_log::debug_whip() {
info!("[WHIP DEBUG | CAUTION SENSITIVE INFO] Delete response {response:#?}");
}
if !response.status().is_success() {
warn!("Failed DELETE of whip resource: {}", response.status())
}
Ok(())
}

232
whip-output.c Normal file
View File

@ -0,0 +1,232 @@
#include "whip-output.h"
static void whip_output_close_unsafe(struct whip_output *output)
{
if (output && output->whip_output) {
char_rtc_output_close(output->whip_output);
char_rtc_output_free(output->whip_output);
output->whip_output = NULL;
}
}
static const char *whip_output_getname(void *type_data)
{
UNUSED_PARAMETER(type_data);
return obs_module_text("Output.Name");
}
static void *whip_output_create(obs_data_t *settings, obs_output_t *obs_output)
{
UNUSED_PARAMETER(settings);
struct whip_output *output = bzalloc(sizeof(struct whip_output));
pthread_mutex_init_value(&output->write_mutex);
output->output = obs_output;
output->whip_output = NULL;
// This needs to be recursive due to `obs_output_signal_stop` calling
// `whip_output_total_bytes_sent` (also guarded by mutex)
if (pthread_mutex_init_recursive(&output->write_mutex) != 0)
goto fail;
return output;
fail:
pthread_mutex_destroy(&output->write_mutex);
bfree(output);
return NULL;
}
static void whip_output_destroy(void *data)
{
struct whip_output *output = data;
pthread_mutex_lock(&output->write_mutex);
whip_output_close_unsafe(output);
pthread_mutex_unlock(&output->write_mutex);
pthread_mutex_destroy(&output->write_mutex);
bfree(output);
}
static int map_rtc_error_to_obs_error(RTCOutputError error)
{
switch (error) {
case RTCOutputError_ConnectFailed:
return OBS_OUTPUT_CONNECT_FAILED;
case RTCOutputError_NetworkError:
return OBS_OUTPUT_ERROR;
default:
blog(LOG_ERROR, "Invalid whip error code: %d", error);
return OBS_OUTPUT_ERROR;
}
}
static void whip_output_error_callback(void *data, RTCOutputError error)
{
struct whip_output *output = data;
pthread_mutex_lock(&output->write_mutex);
if (output->whip_output) {
whip_output_close_unsafe(output);
obs_output_signal_stop(output->output,
map_rtc_error_to_obs_error(error));
}
pthread_mutex_unlock(&output->write_mutex);
}
static bool whip_output_start(void *data)
{
struct whip_output *output = data;
obs_service_t *service;
obs_data_t *service_settings;
const char *url, *bearer_token;
service = obs_output_get_service(output->output);
if (!service)
return false;
if (!obs_output_can_begin_data_capture(output->output, 0))
return false;
if (!obs_output_initialize_encoders(output->output, 0))
return false;
output->whip_output = char_rtc_output_new();
if (!output->whip_output) {
blog(LOG_ERROR, "Unable to initialize whip output");
return false;
}
char_rtc_output_set_error_callback(
output->whip_output,
(RTCOutputErrorCallback)whip_output_error_callback, output);
service_settings = obs_service_get_settings(service);
if (!service_settings)
return false;
url = obs_service_get_url(service);
bearer_token = obs_data_get_string(service_settings, "bearer_token");
char_rtc_output_connect(output->whip_output, url, bearer_token);
obs_output_begin_data_capture(output->output, 0);
obs_data_release(service_settings);
return true;
}
static void whip_output_stop(void *data, uint64_t ts)
{
UNUSED_PARAMETER(ts);
struct whip_output *output = data;
pthread_mutex_lock(&output->write_mutex);
whip_output_close_unsafe(output);
pthread_mutex_unlock(&output->write_mutex);
obs_output_signal_stop(output->output, OBS_OUTPUT_SUCCESS);
}
static void whip_output_data(void *data, struct encoder_packet *packet)
{
struct whip_output *output = data;
int64_t duration = 0;
bool is_audio = false;
if (packet->type == OBS_ENCODER_VIDEO) {
duration = packet->dts_usec - output->video_timestamp;
output->video_timestamp = packet->dts_usec;
} else if (packet->type == OBS_ENCODER_AUDIO) {
is_audio = true;
duration = packet->dts_usec - output->audio_timestamp;
output->audio_timestamp = packet->dts_usec;
}
pthread_mutex_lock(&output->write_mutex);
if (output->whip_output) {
if (!char_rtc_output_write(output->whip_output, packet->data,
packet->size, duration, is_audio)) {
blog(LOG_ERROR,
"Unable to write packets to whip output");
}
}
pthread_mutex_unlock(&output->write_mutex);
}
static void whip_output_defaults(obs_data_t *defaults)
{
UNUSED_PARAMETER(defaults);
}
static obs_properties_t *whip_output_properties(void *unused)
{
UNUSED_PARAMETER(unused);
obs_properties_t *props = obs_properties_create();
return props;
}
static uint64_t whip_output_total_bytes_sent(void *data)
{
struct whip_output *output = data;
pthread_mutex_lock(&output->write_mutex);
uint64_t bytes_sent = 0;
if (output->whip_output)
bytes_sent = char_rtc_output_bytes_sent(output->whip_output);
pthread_mutex_unlock(&output->write_mutex);
return bytes_sent;
}
static int whip_output_dropped_frames(void *data)
{
struct whip_output *output = data;
pthread_mutex_lock(&output->write_mutex);
uint32_t dropped_frames = 0;
if (output->whip_output)
dropped_frames =
char_rtc_output_dropped_frames(output->whip_output);
pthread_mutex_unlock(&output->write_mutex);
return dropped_frames;
}
static int whip_output_connect_time_ms(void *data)
{
struct whip_output *output = data;
pthread_mutex_lock(&output->write_mutex);
uint32_t connect_time = 0;
if (output->whip_output)
connect_time =
char_rtc_output_connect_time_ms(output->whip_output);
pthread_mutex_unlock(&output->write_mutex);
return connect_time;
}
struct obs_output_info whip_output_info = {
.id = "whip_output",
.flags = OBS_OUTPUT_AV | OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE,
.encoded_video_codecs = "h264",
.encoded_audio_codecs = "opus",
.get_name = whip_output_getname,
.create = whip_output_create,
.destroy = whip_output_destroy,
.start = whip_output_start,
.stop = whip_output_stop,
.encoded_packet = whip_output_data,
.get_defaults = whip_output_defaults,
.get_properties = whip_output_properties,
.get_total_bytes = whip_output_total_bytes_sent,
.get_connect_time_ms = whip_output_connect_time_ms,
.get_dropped_frames = whip_output_dropped_frames,
};
void register_whip_output()
{
obs_register_output(&whip_output_info);
}

17
whip-output.h Normal file
View File

@ -0,0 +1,17 @@
#pragma once
#include "obs-module.h"
#include "bindings.h"
#include <util/threading.h>
struct whip_output {
obs_output_t *output;
pthread_mutex_t write_mutex;
int64_t audio_timestamp;
int64_t video_timestamp;
RTCOutput *whip_output;
};
void register_whip_output(void);

100
whip-service.c Normal file
View File

@ -0,0 +1,100 @@
#include "whip-service.h"
struct whip_service_state {
char *server;
};
static const char *whip_service_name(void *type_data)
{
UNUSED_PARAMETER(type_data);
return obs_module_text("Service.Name");
}
static void whip_service_update(void *data, obs_data_t *settings)
{
struct whip_service_state *service = data;
bfree(service->server);
service->server = bstrdup(obs_data_get_string(settings, "server"));
}
static void whip_service_destroy(void *data)
{
struct whip_service_state *service = data;
bfree(service->server);
bfree(service);
}
static void *whip_service_create(obs_data_t *settings, obs_service_t *service)
{
struct whip_service_state *data =
bzalloc(sizeof(struct whip_service_state));
whip_service_update(data, settings);
UNUSED_PARAMETER(service);
return data;
}
static const char *whip_service_url(void *data)
{
struct whip_service_state *service = data;
return service->server;
}
static obs_properties_t *whip_service_properties(void *data)
{
UNUSED_PARAMETER(data);
obs_properties_t *ppts = obs_properties_create();
obs_properties_add_text(ppts, "server", "URL", OBS_TEXT_DEFAULT);
obs_properties_add_text(ppts, "bearer_token",
obs_module_text("Service.BearerToken"),
OBS_TEXT_PASSWORD);
return ppts;
}
static const char *whip_service_get_output_type(void *data)
{
UNUSED_PARAMETER(data);
return "whip_output";
}
static void whip_service_apply_encoder_settings(void *data,
obs_data_t *video_settings,
obs_data_t *audio_settings)
{
UNUSED_PARAMETER(data);
UNUSED_PARAMETER(audio_settings);
// For now, ensure maximum compatibility with webrtc peers
if (video_settings) {
obs_data_set_int(video_settings, "bf", 0);
obs_data_set_string(video_settings, "profile", "baseline");
obs_data_set_string(video_settings, "rate_control", "CBR");
obs_data_set_bool(video_settings, "repeat_headers", true);
}
}
struct obs_service_info whip_service_info = {
.id = "whip_custom",
.get_name = whip_service_name,
.create = whip_service_create,
.destroy = whip_service_destroy,
.update = whip_service_update,
.get_properties = whip_service_properties,
.get_url = whip_service_url,
.get_output_type = whip_service_get_output_type,
.apply_encoder_settings = whip_service_apply_encoder_settings,
};
void register_whip_service()
{
obs_register_service(&whip_service_info);
}

5
whip-service.h Normal file
View File

@ -0,0 +1,5 @@
#pragma once
#include "obs-module.h"
void register_whip_service(void);