api: h.264 video stream (#876)

## Link to GitHub Issue or related Pull Request, if one exists
fixes #875

## Description of change
Adds `-apistream`, an optional HTTP video stream of the mirrored screen.
It listens on the API port +2.

Two endpoints, sharing the same `screen`, `fps` and `q` parameters:

    /stream.mjpg    JPEG frames, for clients with no container support
/stream.h264 H.264 annex-b, for an app driving MediaCodec or
VideoToolbox itself

One encoder per connection, fed by a per-screen pump that always hands
over the newest frame, so a slow reader drops frames instead of building
a backlog. `capture.get_jpg` behaviour is unchanged.

Additional documentation for developers:
https://github.com/spice2x/spice2x.github.io/wiki/Video-Stream

## Testing
This commit is contained in:
bicarus
2026-08-19 03:10:02 -07:00
committed by GitHub
parent 7c50fcc79e
commit 0934cce225
18 changed files with 1089 additions and 17 deletions
+58
View File
@@ -0,0 +1,58 @@
#include "capture_pump.h"
#include <array>
#include <mutex>
#include "hooks/graphics/graphics.h"
namespace api::capture_pump {
namespace {
std::array<std::mutex, GRAPHICS_CAPTURE_SCREEN_NO> CONSUMER_M;
std::mutex CLAIMED_M;
std::array<bool, GRAPHICS_CAPTURE_SCREEN_NO> CLAIMED {};
bool valid_screen(int screen) {
return 0 <= screen && screen < static_cast<int>(GRAPHICS_CAPTURE_SCREEN_NO);
}
}
bool capture_direct(int screen, std::shared_ptr<uint8_t[]> &out, int divide,
uint64_t *timestamp, int *width, int *height) {
if (!valid_screen(screen)) {
return false;
}
std::lock_guard<std::mutex> lock(CONSUMER_M[screen]);
graphics_capture_trigger(screen);
return graphics_capture_receive_raw(
screen, out, divide, timestamp, width, height);
}
bool claim_screen(int screen) {
if (!valid_screen(screen)) {
return false;
}
std::lock_guard<std::mutex> lock(CLAIMED_M);
if (CLAIMED[screen]) {
return false;
}
CLAIMED[screen] = true;
return true;
}
void release_screen(int screen) {
if (!valid_screen(screen)) {
return;
}
std::lock_guard<std::mutex> lock(CLAIMED_M);
CLAIMED[screen] = false;
}
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <memory>
namespace api::capture_pump {
struct Frame {
// packed 24bpp RGB, width * height * 3 bytes
std::shared_ptr<uint8_t[]> pixels;
uint64_t timestamp = 0;
int width = 0;
int height = 0;
};
// the graphics layer has one capture slot per screen, so concurrent waiters would steal
// each other's frames; everything that captures goes through here to keep it serialized
bool capture_direct(int screen, std::shared_ptr<uint8_t[]> &out, int divide,
uint64_t *timestamp = nullptr, int *width = nullptr, int *height = nullptr);
// a screen carries one stream at a time; false when another connection already holds it
bool claim_screen(int screen);
void release_screen(int screen);
}
+224
View File
@@ -0,0 +1,224 @@
#include "h264_stream.h"
#ifdef SPICE_H264
#include <vector>
#include <x264.h>
#include "util/logging.h"
namespace api {
namespace {
// BT.601 limited range, the range every decoder assumes for H.264 without
// explicit colour metadata
inline uint8_t rgb_to_y(int r, int g, int b) {
return static_cast<uint8_t>(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16);
}
inline uint8_t rgb_to_u(int r, int g, int b) {
return static_cast<uint8_t>(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128);
}
inline uint8_t rgb_to_v(int r, int g, int b) {
return static_cast<uint8_t>(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128);
}
// a bare annex-b elementary stream, one encoder per connection so every client
// starts on its own keyframe. no container, so nothing here keeps a media clock
class H264Writer : public StreamWriter {
public:
H264Writer(int quality, int fps) : quality(quality), fps(fps) {}
~H264Writer() override {
this->close();
}
std::string content_type() const override {
return "video/h264";
}
bool write(const StreamSend &send, const capture_pump::Frame &frame) override {
// I420 needs even dimensions
const int width = frame.width & ~1;
const int height = frame.height & ~1;
if (width <= 0 || height <= 0) {
return true;
}
if (this->encoder == nullptr) {
if (!this->open(width, height)) {
return false;
}
} else if (width != this->width || height != this->height) {
// the encoder is fixed at the size it opened with; let the client reconnect
log_info("api::stream", "capture size changed, ending H.264 client");
return false;
}
this->convert(frame.pixels.get(), frame.width);
this->picture.i_pts = this->frame_index;
x264_nal_t *nals = nullptr;
int nal_count = 0;
x264_picture_t picture_out;
const int size = x264_encoder_encode(
this->encoder, &nals, &nal_count, &this->picture, &picture_out);
if (size < 0) {
log_warning("api::stream", "H.264 encode failed");
return false;
}
this->frame_index++;
if (size == 0) {
return true;
}
// x264 lays every NAL of the frame out back to back. an SEI or delimiter
// carries no picture, so only the parameter sets and the slice go through
this->annexb.clear();
for (int i = 0; i < nal_count; i++) {
switch (nals[i].i_type) {
case NAL_SEI:
case NAL_AUD:
case NAL_FILLER:
continue;
default:
break;
}
this->annexb.insert(this->annexb.end(),
nals[i].p_payload, nals[i].p_payload + nals[i].i_payload);
}
if (this->annexb.empty()) {
return true;
}
return send(this->annexb.data(), this->annexb.size());
}
private:
bool open(int width, int height) {
x264_param_t param;
if (x264_param_default_preset(&param, "ultrafast", "zerolatency") < 0) {
return false;
}
param.i_csp = X264_CSP_I420;
param.i_width = width;
param.i_height = height;
param.i_fps_num = this->fps;
param.i_fps_den = 1;
param.i_threads = 1;
param.b_annexb = 1;
// SPS/PPS ahead of every IDR, so a client can start decoding cold
param.b_repeat_headers = 1;
// a keyframe every two seconds bounds how long a new client waits
param.i_keyint_max = this->fps * 2;
param.i_log_level = X264_LOG_NONE;
param.rc.i_rc_method = X264_RC_CRF;
param.rc.f_rf_constant = 40.0f - (this->quality * 0.25f);
// baseline keeps hardware decode available on the widest range of phones
if (x264_param_apply_profile(&param, "baseline") < 0) {
return false;
}
this->encoder = x264_encoder_open(&param);
if (this->encoder == nullptr) {
log_warning("api::stream", "could not open the H.264 encoder");
return false;
}
if (x264_picture_alloc(&this->picture, X264_CSP_I420, width, height) < 0) {
this->close();
return false;
}
this->picture_ready = true;
this->width = width;
this->height = height;
return true;
}
void close() {
if (this->picture_ready) {
x264_picture_clean(&this->picture);
this->picture_ready = false;
}
if (this->encoder != nullptr) {
x264_encoder_close(this->encoder);
this->encoder = nullptr;
}
}
// packed 24bpp RGB to I420, averaging each 2x2 block for the chroma planes
void convert(const uint8_t *rgb, int source_width) {
uint8_t *plane_y = this->picture.img.plane[0];
uint8_t *plane_u = this->picture.img.plane[1];
uint8_t *plane_v = this->picture.img.plane[2];
const int stride_y = this->picture.img.i_stride[0];
const int stride_u = this->picture.img.i_stride[1];
const int stride_v = this->picture.img.i_stride[2];
for (int y = 0; y < this->height; y++) {
const uint8_t *row = rgb + static_cast<size_t>(y) * source_width * 3;
uint8_t *out_y = plane_y + static_cast<size_t>(y) * stride_y;
for (int x = 0; x < this->width; x++) {
const uint8_t *pixel = row + x * 3;
out_y[x] = rgb_to_y(pixel[0], pixel[1], pixel[2]);
}
}
for (int y = 0; y < this->height / 2; y++) {
const uint8_t *row0 = rgb + static_cast<size_t>(y * 2) * source_width * 3;
const uint8_t *row1 = row0 + static_cast<size_t>(source_width) * 3;
uint8_t *out_u = plane_u + static_cast<size_t>(y) * stride_u;
uint8_t *out_v = plane_v + static_cast<size_t>(y) * stride_v;
for (int x = 0; x < this->width / 2; x++) {
const uint8_t *p00 = row0 + (x * 2) * 3;
const uint8_t *p01 = p00 + 3;
const uint8_t *p10 = row1 + (x * 2) * 3;
const uint8_t *p11 = p10 + 3;
const int r = (p00[0] + p01[0] + p10[0] + p11[0] + 2) / 4;
const int g = (p00[1] + p01[1] + p10[1] + p11[1] + 2) / 4;
const int b = (p00[2] + p01[2] + p10[2] + p11[2] + 2) / 4;
out_u[x] = rgb_to_u(r, g, b);
out_v[x] = rgb_to_v(r, g, b);
}
}
}
int quality;
int fps;
int width = 0;
int height = 0;
int64_t frame_index = 0;
std::vector<uint8_t> annexb;
x264_t *encoder = nullptr;
x264_picture_t picture {};
bool picture_ready = false;
};
}
std::unique_ptr<StreamWriter> make_h264_writer(int quality, int fps) {
return std::make_unique<H264Writer>(quality, fps);
}
}
#endif // SPICE_H264
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <memory>
#include "stream_format.h"
namespace api {
// bare annex-b H.264; null when the build has no encoder
std::unique_ptr<StreamWriter> make_h264_writer(int quality, int fps);
}
+12 -3
View File
@@ -2,8 +2,10 @@
#include <functional>
#include <mutex>
#include <unordered_map>
#include "api/capture_pump.h"
#include "external/rapidjson/document.h"
#include "hooks/graphics/graphics.h"
#include "hooks/graphics/jpeg_encoder.h"
#include "util/crypt.h"
using namespace std::placeholders;
@@ -118,9 +120,16 @@ namespace api::modules {
uint64_t timestamp = 0;
int width = 0;
int height = 0;
graphics_capture_trigger(screen);
bool success = graphics_capture_receive_jpeg(
screen, CAPTURE_BUFFER, quality, divide, &timestamp, &width, &height);
std::shared_ptr<uint8_t[]> pixels;
bool success = capture_pump::capture_direct(
screen, pixels, divide, &timestamp, &width, &height);
if (success) {
CAPTURE_BUFFER.clear();
success = jpeg_encoder::encode(
CAPTURE_BUFFER, pixels.get(), width, height, quality);
}
if (success) {
add_jpeg_response(screen, timestamp, width, height, CAPTURE_BUFFER, res);
+70
View File
@@ -0,0 +1,70 @@
#include "stream_format.h"
#include <vector>
#include "h264_stream.h"
#include "hooks/graphics/jpeg_encoder.h"
namespace api {
namespace {
#ifdef SPICE_JPEG
constexpr const char *MJPEG_BOUNDARY = "spice2xframe";
// multipart/x-mixed-replace: every frame is a standalone JPEG, no inter-frame state
class MjpegWriter : public StreamWriter {
public:
explicit MjpegWriter(int quality) : quality(quality) {}
std::string content_type() const override {
return std::string("multipart/x-mixed-replace; boundary=") + MJPEG_BOUNDARY;
}
bool write(const StreamSend &send, const capture_pump::Frame &frame) override {
this->jpeg.clear();
if (!jpeg_encoder::encode(
this->jpeg, frame.pixels.get(),
frame.width, frame.height, this->quality)) {
// a frame the encoder rejects is not worth dropping the client over
return true;
}
const std::string part =
"--" + std::string(MJPEG_BOUNDARY) + "\r\n"
"Content-Type: image/jpeg\r\n"
"Content-Length: " + std::to_string(this->jpeg.size()) + "\r\n"
"\r\n";
return send(part.data(), part.size())
&& send(this->jpeg.data(), this->jpeg.size())
&& send("\r\n", 2);
}
private:
int quality;
std::vector<uint8_t> jpeg;
};
#endif
}
// both parameters go unused on toolchains that compile in neither format
std::unique_ptr<StreamWriter> make_stream_writer(
const std::string &path, [[maybe_unused]] int quality, [[maybe_unused]] int fps) {
#ifdef SPICE_JPEG
if (path == "/stream.mjpg") {
return std::make_unique<MjpegWriter>(quality);
}
#endif
#ifdef SPICE_H264
if (path == "/stream.h264") {
return make_h264_writer(quality, fps);
}
#endif
return nullptr;
}
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <functional>
#include <memory>
#include <string>
#include "capture_pump.h"
namespace api {
// writes bytes to the client; false once the connection is gone
using StreamSend = std::function<bool(const void *, size_t)>;
// one wire format, instantiated per connection so it can keep encoder state across frames
class StreamWriter {
public:
virtual ~StreamWriter() = default;
StreamWriter(const StreamWriter &) = delete;
StreamWriter &operator=(const StreamWriter &) = delete;
// value for the HTTP Content-Type response header
virtual std::string content_type() const = 0;
// for formats that open with an init segment; runs once before any frame
virtual bool begin(const StreamSend &send) { return true; }
virtual bool write(const StreamSend &send, const capture_pump::Frame &frame) = 0;
protected:
StreamWriter() = default;
};
// null when the path does not name a format this build supports
std::unique_ptr<StreamWriter> make_stream_writer(
const std::string &path, int quality, int fps);
}
+427
View File
@@ -0,0 +1,427 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include "stream_server.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <map>
#include <string>
#include <thread>
#include <vector>
#include "capture_pump.h"
#include "hooks/graphics/graphics.h"
#include "stream_format.h"
#include "util/logging.h"
#include "util/utils.h"
namespace api {
namespace {
struct HttpRequest {
std::string method;
std::string path;
std::map<std::string, std::string> query;
};
bool send_all(SOCKET socket, const void *data, size_t size) {
auto cursor = reinterpret_cast<const char *>(data);
size_t remaining = size;
while (remaining > 0) {
const int sent = send(socket, cursor, static_cast<int>(remaining), 0);
if (sent <= 0) {
return false;
}
cursor += sent;
remaining -= static_cast<size_t>(sent);
}
return true;
}
bool send_all(SOCKET socket, const std::string &text) {
return send_all(socket, text.data(), text.size());
}
std::string url_decode(const std::string &input) {
std::string out;
out.reserve(input.size());
for (size_t i = 0; i < input.size(); i++) {
if (input[i] == '+') {
out.push_back(' ');
} else if (input[i] == '%' && i + 2 < input.size()
&& isxdigit(static_cast<unsigned char>(input[i + 1]))
&& isxdigit(static_cast<unsigned char>(input[i + 2]))) {
out.push_back(static_cast<char>(
std::stoi(input.substr(i + 1, 2), nullptr, 16)));
i += 2;
} else {
out.push_back(input[i]);
}
}
return out;
}
void parse_query(const std::string &query, HttpRequest &request) {
size_t pos = 0;
while (pos < query.size()) {
auto end = query.find('&', pos);
if (end == std::string::npos) {
end = query.size();
}
const auto pair = query.substr(pos, end - pos);
const auto split = pair.find('=');
if (split != std::string::npos && split > 0) {
request.query[url_decode(pair.substr(0, split))] =
url_decode(pair.substr(split + 1));
}
pos = end + 1;
}
}
// reads the request head only; anything oversized or malformed is refused
bool read_request(SOCKET socket, size_t size_limit, HttpRequest &request) {
std::string head;
char buffer[1024];
while (head.find("\r\n\r\n") == std::string::npos) {
if (head.size() >= size_limit) {
return false;
}
// read no further than the limit, so the head cannot overshoot it
const size_t budget = std::min(sizeof(buffer), size_limit - head.size());
const int received = recv(socket, buffer, static_cast<int>(budget), 0);
if (received <= 0) {
return false;
}
head.append(buffer, static_cast<size_t>(received));
}
const auto line_end = head.find("\r\n");
const auto line = head.substr(0, line_end);
const auto method_end = line.find(' ');
if (method_end == std::string::npos) {
return false;
}
const auto target_end = line.find(' ', method_end + 1);
if (target_end == std::string::npos) {
return false;
}
request.method = line.substr(0, method_end);
auto target = line.substr(method_end + 1, target_end - method_end - 1);
const auto query_start = target.find('?');
if (query_start != std::string::npos) {
parse_query(target.substr(query_start + 1), request);
target = target.substr(0, query_start);
}
request.path = url_decode(target);
return true;
}
int query_int(const HttpRequest &request, const std::string &name, int fallback,
int min, int max) {
const auto pos = request.query.find(name);
if (pos == request.query.end()) {
return fallback;
}
try {
return std::clamp(std::stoi(pos->second), min, max);
} catch (const std::exception &) {
return fallback;
}
}
void send_error(SOCKET socket, const char *status) {
const std::string response =
std::string("HTTP/1.0 ") + status + "\r\n"
"Content-Length: 0\r\n"
"Connection: close\r\n"
"\r\n";
send_all(socket, response);
}
}
StreamServer::StreamServer(unsigned short port)
: port(port)
{
if (!this->open_listener()) {
// the stream was asked for explicitly, so say plainly that it is not there
log_warning("api::stream",
"the video stream is not available on port {}", this->port);
return;
}
this->running = true;
this->acceptor = std::thread([this] {
this->accept_worker();
});
// deliberately not logging a full URL; local IPs would leak into shared logs
log_info("api::stream", "video stream is listening on port: {}", this->port);
log_warning("api::stream",
"the video stream is unauthenticated - anyone who can reach port {} can watch "
"the game screen", this->port);
}
bool StreamServer::open_listener() {
WSADATA wsa_data;
const int error = WSAStartup(MAKEWORD(2, 2), &wsa_data);
if (error != 0) {
log_warning("api::stream", "WSAStartup() returned {}", error);
return false;
}
this->wsa_started = true;
this->listener = socket(AF_INET, SOCK_STREAM, 0);
if (this->listener == INVALID_SOCKET) {
log_warning("api::stream", "could not create listener socket: {}",
get_last_error_string());
return false;
}
int opt_enable = 1;
if (setsockopt(this->listener, SOL_SOCKET, SO_REUSEADDR,
reinterpret_cast<const char *>(&opt_enable), sizeof(int)) == -1) {
log_warning("api::stream", "could not set socket option SO_REUSEADDR: {}",
get_last_error_string());
}
sockaddr_in server_address {};
server_address.sin_family = AF_INET;
server_address.sin_port = htons(this->port);
server_address.sin_addr.s_addr = INADDR_ANY;
if (bind(this->listener, (sockaddr *) &server_address, sizeof(sockaddr)) == -1) {
log_warning("api::stream", "could not bind socket on port {}: {}",
this->port, get_last_error_string());
closesocket(this->listener);
this->listener = INVALID_SOCKET;
return false;
}
if (listen(this->listener, server_backlog) == -1) {
log_warning("api::stream", "could not listen on port {}: {}",
this->port, get_last_error_string());
closesocket(this->listener);
this->listener = INVALID_SOCKET;
return false;
}
return true;
}
StreamServer::~StreamServer() {
this->running = false;
if (this->listener != INVALID_SOCKET) {
closesocket(this->listener);
this->listener = INVALID_SOCKET;
}
// drops the client threads out of their blocking send/recv
{
std::lock_guard<std::mutex> lock(this->clients_m);
for (auto &client : this->clients) {
if (client.socket != INVALID_SOCKET) {
::shutdown(client.socket, SD_BOTH);
}
}
}
if (this->acceptor.joinable()) {
this->acceptor.join();
}
// joining is what guarantees no client thread outlives this object
for (auto &client : this->clients) {
if (client.thread.joinable()) {
client.thread.join();
}
}
if (this->wsa_started) {
WSACleanup();
}
}
void StreamServer::accept_worker() {
while (this->running) {
sockaddr_in client_address {};
int client_address_size = sizeof(sockaddr_in);
const SOCKET client = accept(
this->listener, (sockaddr *) &client_address, &client_address_size);
if (client == INVALID_SOCKET) {
// on shutdown the listener is closed under us; otherwise do not spin
if (this->running) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
continue;
}
if (!this->running) {
closesocket(client);
break;
}
char address_data[INET_ADDRSTRLEN] {};
inet_ntop(AF_INET, &client_address.sin_addr, address_data, INET_ADDRSTRLEN);
std::string address(address_data);
// every client costs an encode and real bandwidth, so the cap protects the game
int slot = -1;
{
std::lock_guard<std::mutex> lock(this->clients_m);
for (size_t i = 0; i < this->clients.size(); i++) {
if (!this->clients[i].active) {
this->clients[i].active = true;
this->clients[i].socket = client;
slot = static_cast<int>(i);
break;
}
}
}
if (slot < 0) {
log_warning("api::stream", "client limit of {} hit", client_limit);
send_error(client, "503 Service Unavailable");
closesocket(client);
continue;
}
// this thread is the only one that touches the thread objects, so the slot's
// previous occupant gets reaped here rather than being detached
if (this->clients[slot].thread.joinable()) {
this->clients[slot].thread.join();
}
this->clients[slot].thread = std::thread([this, slot, client, address] {
this->client_worker(slot, client, address);
});
}
}
void StreamServer::client_worker(int slot, SOCKET socket, std::string address) {
DWORD timeout = request_timeout_ms;
setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char *>(&timeout), sizeof(timeout));
timeout = send_timeout_ms;
setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO,
reinterpret_cast<const char *>(&timeout), sizeof(timeout));
int opt_enable = 1;
setsockopt(socket, IPPROTO_TCP, TCP_NODELAY,
reinterpret_cast<const char *>(&opt_enable), sizeof(int));
// whatever sits in the send buffer is already stale, and the default holds about a
// third of a second of H.264 because the bitrate is so low. keeping it small makes a
// slow reader block the sender, which then skips to the newest frame instead of
// handing over a backlog
int send_buffer = send_buffer_bytes;
setsockopt(socket, SOL_SOCKET, SO_SNDBUF,
reinterpret_cast<const char *>(&send_buffer), sizeof(send_buffer));
HttpRequest request;
if (read_request(socket, request_size_limit, request)) {
if (request.method != "GET") {
send_error(socket, "405 Method Not Allowed");
} else {
const int fps = query_int(request, "fps", 30, 1, fps_limit);
const int quality = query_int(request, "q", 70, 1, 100);
auto writer = make_stream_writer(request.path, quality, fps);
if (!writer) {
send_error(socket, "404 Not Found");
} else {
// screen 1 is the subscreen in every game that has one; single-screen games
// only ever register screen 0, so resolve the default against what exists
int screen = query_int(request, "screen", -1, 0,
static_cast<int>(GRAPHICS_CAPTURE_SCREEN_NO) - 1);
if (screen < 0) {
std::vector<int> screens;
graphics_screens_get(screens);
screen = std::find(screens.begin(), screens.end(), 1) != screens.end()
? 1 : 0;
}
if (!capture_pump::claim_screen(screen)) {
log_warning("api::stream",
"screen {} is already being streamed, refusing {}",
screen, address);
send_error(socket, "503 Service Unavailable");
} else {
log_info("api::stream",
"client connected: {} ({}, screen={}, fps={}, quality={})",
address, request.path, screen, fps, quality);
const std::string header =
"HTTP/1.0 200 OK\r\n"
"Connection: close\r\n"
"Cache-Control: no-store, no-cache, must-revalidate\r\n"
"Pragma: no-cache\r\n"
"Content-Type: " + writer->content_type() + "\r\n"
"\r\n";
const StreamSend stream_send = [socket](const void *data, size_t size) {
return send_all(socket, data, size);
};
if (send_all(socket, header) && writer->begin(stream_send)) {
const auto interval = std::chrono::microseconds(1000000 / fps);
while (this->running) {
const auto started = std::chrono::steady_clock::now();
capture_pump::Frame frame;
const bool ok = capture_pump::capture_direct(
screen, frame.pixels, 1,
&frame.timestamp, &frame.width, &frame.height);
if (ok && frame.pixels
&& !writer->write(stream_send, frame)) {
break;
}
// a failed capture still paces, or a stalled game spins this
std::this_thread::sleep_until(started + interval);
}
}
capture_pump::release_screen(screen);
log_info("api::stream", "client disconnected: {}", address);
}
}
}
}
{
std::lock_guard<std::mutex> lock(this->clients_m);
this->clients[slot].socket = INVALID_SOCKET;
this->clients[slot].active = false;
}
closesocket(socket);
}
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <array>
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <winsock2.h>
namespace api {
class StreamServer {
public:
explicit StreamServer(unsigned short port);
~StreamServer();
StreamServer(const StreamServer &) = delete;
StreamServer &operator=(const StreamServer &) = delete;
private:
// configuration
static constexpr int server_backlog = 4;
static constexpr int client_limit = 4;
static constexpr int request_size_limit = 8 * 1024;
static constexpr int request_timeout_ms = 5000;
static constexpr int send_timeout_ms = 5000;
// small enough that a low bitrate stream cannot hide a backlog of stale frames in it
static constexpr int send_buffer_bytes = 16 * 1024;
static constexpr int fps_limit = 60;
struct Client {
std::thread thread;
SOCKET socket = INVALID_SOCKET;
bool active = false;
};
void accept_worker();
bool open_listener();
void client_worker(int slot, SOCKET socket, std::string address);
unsigned short port;
SOCKET listener = INVALID_SOCKET;
bool wsa_started = false;
std::atomic_bool running { false };
std::thread acceptor;
std::mutex clients_m;
// socket and active are guarded by clients_m; only the acceptor touches thread
std::array<Client, client_limit> clients;
};
}