Introduction
Hello! π
In this advanced WebRTC tutorial I will show you how to stream your camera to a HTML page using WebRTC, GStreamer and C++. We will be using boost to handle the signaling. By the end of this tutorial you should have a simple understanding on WebRTC GStreamer. π
Requirements
- GStreamer and its development libraries
- Boost libraries
- CMake for building the project
- A C++ compiler
- Basic C++ Knowledge
Creating the Project
First we need a place to house our projects files, create a new directory like so:
mkdir webrtc-stream && cd webrtc-stream
First we need to create a build file in order to build the completed project, create a new file called "CMakeLists.txt" and populate it with the following:
cmake_minimum_required(VERSION 3.10)
# Set the project name and version
project(webrtc_server VERSION 1.0)
# Specify the C++ standard
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# Find required packages
find_package(PkgConfig REQUIRED)
pkg_check_modules(GST REQUIRED gstreamer-1.0 gstreamer-webrtc-1.0 gstreamer-sdp-1.0)
find_package(Boost 1.65 REQUIRED COMPONENTS system filesystem json)
# Include directories
include_directories(${GST_INCLUDE_DIRS} ${Boost_INCLUDE_DIRS})
# Add the executable
add_executable(webrtc_server main.cpp)
# Link libraries
target_link_libraries(webrtc_server ${GST_LIBRARIES} Boost::system Boost::filesystem Boost::json)
# Set properties
set_target_properties(webrtc_server PROPERTIES
CXX_STANDARD 14
CXX_STANDARD_REQUIRED ON
)
# Specify additional directories for the linker
link_directories(${GST_LIBRARY_DIRS})
# Print project info
message(STATUS "Project: ${PROJECT_NAME}")
message(STATUS "Version: ${PROJECT_VERSION}")
message(STATUS "C++ Standard: ${CMAKE_CXX_STANDARD}")
message(STATUS "Boost Libraries: ${Boost_LIBRARIES}")
message(STATUS "GStreamer Libraries: ${GST_LIBRARIES}")
The above links all the required libraries together in order to build the code into an executable that can be executed.
Now we can get on to coding the project. π₯Έ
Coding the Project
Now we can start coding the source code for the project, create a new file called "main.cpp", we will start by importing the necessary headers for GStreamer, WebRTC, Boost and standard libraries:
#define GST_USE_UNSTABLE_API
#include <gst/gst.h>
#include <gst/webrtc/webrtc.h>
#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <boost/json.hpp>
#include <iostream>
#include <thread>
namespace beast = boost::beast;
namespace http = beast::http;
namespace websocket = beast::websocket;
namespace net = boost::asio;
using tcp = net::ip::tcp;
using namespace boost::json;
Next we will be define constants that will be used later, mainly the STUN server and port that the server will listen on:
#define STUN_SERVER "stun://stun.l.google.com:19302"
#define SERVER_PORT 8000
Now we will declare global variables for the GStreamer main loop and pipeline elements:
GMainLoop *loop;
GstElement *pipeline, *webrtcbin;
Next we will create the functions to handle each of the events. First one being a function that sends ICE candidates to the WebSocket client:
void send_ice_candidate_message(websocket::stream<tcp::socket>& ws, guint mlineindex, gchar *candidate)
{
std::cout << "Sending ICE candidate: mlineindex=" << mlineindex << ", candidate=" << candidate << std::endl;
object ice_json;
ice_json["candidate"] = candidate;
ice_json["sdpMLineIndex"] = mlineindex;
object msg_json;
msg_json["type"] = "candidate";
msg_json["ice"] = ice_json;
std::string text = serialize(msg_json);
ws.write(net::buffer(text));
std::cout << "ICE candidate sent" << std::endl;
}
The next "on_answer_created" function handles the creation of a WebRTC answer and sends it back to the client:
void on_answer_created(GstPromise *promise, gpointer user_data)
{
std::cout << "Answer created" << std::endl;
websocket::stream<tcp::socket>* ws = static_cast<websocket::stream<tcp::socket>*>(user_data);
GstWebRTCSessionDescription *answer = NULL;
const GstStructure *reply = gst_promise_get_reply(promise);
gst_structure_get(reply, "answer", GST_TYPE_WEBRTC_SESSION_DESCRIPTION, &answer, NULL);
GstPromise *local_promise = gst_promise_new();
g_signal_emit_by_name(webrtcbin, "set-local-description", answer, local_promise);
object sdp_json;
sdp_json["type"] = "answer";
sdp_json["sdp"] = gst_sdp_message_as_text(answer->sdp);
std::string text = serialize(sdp_json);
ws->write(net::buffer(text));
std::cout << "Local description set and answer sent: " << text << std::endl;
gst_webrtc_session_description_free(answer);
}
The next function is just a placeholder for handling negotiation events, this event is not needed in this example:
void on_negotiation_needed(GstElement *webrtc, gpointer user_data)
{
std::cout << "Negotiation needed" << std::endl;
}
The "on_set_remote_description" function sets the remote description and creates an answer:
void on_set_remote_description(GstPromise *promise, gpointer user_data)
{
std::cout << "Remote description set, creating answer" << std::endl;
websocket::stream<tcp::socket>* ws = static_cast<websocket::stream<tcp::socket>*>(user_data);
GstPromise *answer_promise = gst_promise_new_with_change_func(on_answer_created, ws, NULL);
g_signal_emit_by_name(webrtcbin, "create-answer", NULL, answer_promise);
}
The "on_ice_candidate" function handles ICE candidate events and sends them to the WebSocket client:
void on_ice_candidate(GstElement *webrtc, guint mlineindex, gchar *candidate, gpointer user_data)
{
std::cout << "ICE candidate generated: mlineindex=" << mlineindex << ", candidate=" << candidate << std::endl;
websocket::stream<tcp::socket>* ws = static_cast<websocket::stream<tcp::socket>*>(user_data);
send_ice_candidate_message(*ws, mlineindex, candidate);
}
The "handle_websocket_session" function manages the WebSocket connection, setting up the GStreamer pipeline and handling both SDP and ICE messages:
void handle_websocket_session(tcp::socket socket)
{
try
{
websocket::stream<tcp::socket> ws{std::move(socket)};
ws.accept();
std::cout << "WebSocket connection accepted" << std::endl;
GstStateChangeReturn ret;
GError *error = NULL;
pipeline = gst_pipeline_new("pipeline");
GstElement *v4l2src = gst_element_factory_make("v4l2src", "source");
GstElement *videoconvert = gst_element_factory_make("videoconvert", "convert");
GstElement *queue = gst_element_factory_make("queue", "queue");
GstElement *vp8enc = gst_element_factory_make("vp8enc", "encoder");
GstElement *rtpvp8pay = gst_element_factory_make("rtpvp8pay", "pay");
webrtcbin = gst_element_factory_make("webrtcbin", "sendrecv");
if (!pipeline || !v4l2src || !videoconvert || !queue || !vp8enc || !rtpvp8pay || !webrtcbin)
{
g_printerr("Not all elements could be created.\n");
return;
}
g_object_set(v4l2src, "device", "/dev/video0", NULL);
g_object_set(vp8enc, "deadline", 1, NULL);
gst_bin_add_many(GST_BIN(pipeline), v4l2src, videoconvert, queue, vp8enc, rtpvp8pay, webrtcbin, NULL);
if (!gst_element_link_many(v4l2src, videoconvert, queue, vp8enc, rtpvp8pay, NULL))
{
g_printerr("Elements could not be linked.\n");
gst_object_unref(pipeline);
return;
}
GstPad *rtp_src_pad = gst_element_get_static_pad(rtpvp8pay, "src");
GstPad *webrtc_sink_pad = gst_element_get_request_pad(webrtcbin, "sink_%u");
gst_pad_link(rtp_src_pad, webrtc_sink_pad);
gst_object_unref(rtp_src_pad);
gst_object_unref(webrtc_sink_pad);
g_signal_connect(webrtcbin, "on-negotiation-needed", G_CALLBACK(on_negotiation_needed), &ws);
g_signal_connect(webrtcbin, "on-ice-candidate", G_CALLBACK(on_ice_candidate), &ws);
ret = gst_element_set_state(pipeline, GST_STATE_PLAYING);
if (ret == GST_STATE_CHANGE_FAILURE)
{
g_printerr("Unable to set the pipeline to the playing state.\n");
gst_object_unref(pipeline);
return;
}
std::cout << "GStreamer pipeline set to playing" << std::endl;
for (;;)
{
beast::flat_buffer buffer;
ws.read(buffer);
auto text = beast::buffers_to_string(buffer.data());
value jv = parse(text);
object obj = jv.as_object();
std::string type = obj["type"].as_string().c_str();
if (type == "offer")
{
std::cout << "Received offer: " << text << std::endl;
std::string sdp = obj["sdp"].as_string().c_str();
GstSDPMessage *sdp_message;
gst_sdp_message_new_from_text(sdp.c_str(), &sdp_message);
GstWebRTCSessionDescription *offer = gst_webrtc_session_description_new(GST_WEBRTC_SDP_TYPE_OFFER, sdp_message);
GstPromise *promise = gst_promise_new_with_change_func(on_set_remote_description, &ws, NULL);
g_signal_emit_by_name(webrtcbin, "set-remote-description", offer, promise);
gst_webrtc_session_description_free(offer);
std::cout << "Setting remote description" << std::endl;
}
else if (type == "candidate")
{
std::cout << "Received ICE candidate: " << text << std::endl;
object ice = obj["ice"].as_object();
std::string candidate = ice["candidate"].as_string().c_str();
guint sdpMLineIndex = ice["sdpMLineIndex"].as_int64();
g_signal_emit_by_name(webrtcbin, "add-ice-candidate", sdpMLineIndex, candidate.c_str());
std::cout << "Added ICE candidate" << std::endl;
}
}
}
catch (beast::system_error const& se)
{
if (se.code() != websocket::error::closed)
{
std::cerr << "Error: " << se.code().message() << std::endl;
}
}
catch (std::exception const& e)
{
std::cerr << "Exception: " << e.what() << std::endl;
}
}
The next "start_server" function initializes the server, sccepting TCP connections and spawning new threads to handle each connection:
void start_server()
{
try
{
net::io_context ioc{1};
tcp::acceptor acceptor{ioc, tcp::endpoint{tcp::v4(), SERVER_PORT}};
for (;;)
{
tcp::socket socket{ioc};
acceptor.accept(socket);
std::cout << "Accepted new TCP connection" << std::endl;
std::thread{handle_websocket_session, std::move(socket)}.detach();
}
}
catch (std::exception const& e)
{
std::cerr << "Exception: " << e.what() << std::endl;
}
}
Finally we just need to create the final main function to initialize GStreamer, start the server and run the main loop:
int main(int argc, char *argv[])
{
gst_init(&argc, &argv);
loop = g_main_loop_new(NULL, FALSE);
std::cout << "Starting WebRTC server" << std::endl;
std::thread server_thread(start_server);
g_main_loop_run(loop);
server_thread.join();
gst_element_set_state(pipeline, GST_STATE_NULL);
gst_object_unref(pipeline);
g_main_loop_unref(loop);
std::cout << "WebRTC server stopped" << std::endl;
return 0;
}
Done, now we can finally build the project! π
Building the Project
To build the above source code into an executable first create a new directory called build:
mkdir build && cd build
Build the project:
cmake ..
make
If all goes well the project should be built successfully and you should have an executable.
Next we need to create a page to view the stream. πΈ
Creating the Frontend
Create a new directory called "public" and in it create a new html file called "index.html" and populate it with the following code:
<!DOCTYPE html>
<html>
<head>
<title>WebRTC Stream</title>
</head>
<body>
<video id="video" autoplay playsinline muted></video>
<script>
const video = document.getElementById('video');
const signaling = new WebSocket('ws://localhost:8000/ws');
let pc = new RTCPeerConnection({
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
});
signaling.onmessage = async (event) => {
const data = JSON.parse(event.data);
console.log('Received signaling message:', data);
if (data.type === 'answer') {
console.log('Setting remote description with answer');
await pc.setRemoteDescription(new RTCSessionDescription(data));
} else if (data.type === 'candidate') {
console.log('Adding ICE candidate:', data.ice);
await pc.addIceCandidate(new RTCIceCandidate(data.ice));
}
};
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('Sending ICE candidate:', event.candidate);
signaling.send(JSON.stringify({
type: 'candidate',
ice: event.candidate
}));
}
};
pc.ontrack = (event) => {
console.log('Received track:', event);
if (event.track.kind === 'video') {
console.log('Attaching video track to video element');
video.srcObject = event.streams[0];
video.play().catch(error => {
console.error('Error playing video:', error);
});
video.load();
}
};
pc.oniceconnectionstatechange = () => {
console.log('ICE connection state:', pc.iceConnectionState);
};
pc.onicegatheringstatechange = () => {
console.log('ICE gathering state:', pc.iceGatheringState);
};
pc.onsignalingstatechange = () => {
console.log('Signaling state:', pc.signalingState);
};
async function start() {
pc.addTransceiver('video', {direction: 'recvonly'});
const offer = await pc.createOffer();
console.log('Created offer:', offer);
await pc.setLocalDescription(offer);
console.log('Set local description with offer');
signaling.send(JSON.stringify({type: 'offer', sdp: pc.localDescription.sdp}));
}
start();
</script>
</body>
</html>
The above can be explained in my other WebRTC tutorials, but it simple communicates with the signalling server and when a remote stream is received plays the video in the video HTML element.
Done now we can actually run the project! π
Running the Project
To run the project simple execute the following command:
./webrtc_server
To run the html page we will use a python module:
python3 -m http.server 9999
Navigate your browser to http://localhost:9999 and on load you should see your camera showing in the video element like so:
Done! π
Considerations
In order to improve the above, I would like to implement the following:
- Handle multiple viewers
- Handle receiving a stream from HTML
- Creating an SFU
- Recording
Conclusion
In this tutorial I have shown you how to stream your camera using native C++, GStreamer and view the stream in a HTML page. I hope this tutorial has taught you something, I certainly had a lot of fun creating it.
As always you can find the source code for the project on my Github:
https://github.com/ethand91/webrtc-gstreamer
Happy Coding! π
Like my work? I post about a variety of topics, if you would like to see more please like and follow me.
Also I love coffee.
If you are looking to learn Algorithm Patterns to ace the coding interview I recommend the [following course](https://algolab.so/p/algorithms-and-data-structure-video-course?affcode=1413380_bzrepgch
Top comments (0)