mirror of
https://github.com/spice2x/spice2x.github.io.git
synced 2026-10-03 00:38:14 -07:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¶m, "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(¶m, "baseline") < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this->encoder = x264_encoder_open(¶m);
|
||||
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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, ×tamp, &width, &height);
|
||||
|
||||
std::shared_ptr<uint8_t[]> pixels;
|
||||
bool success = capture_pump::capture_direct(
|
||||
screen, pixels, divide, ×tamp, &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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user