diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index f979a0d..1d8b5c9 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -64,6 +64,7 @@ jobs:
set -ex
sudo apt install -y \
libopenblas-dev libopenblas0 \
+ libcurl4-openssl-dev \
|| true
export OPENBLAS_HOME=/lib/x86_64-linux-gnu/
cmake -S . -B build \
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0c10d61..c8fa980 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -36,6 +36,7 @@ find_package(libobs REQUIRED)
find_package(obs-frontend-api REQUIRED)
include(cmake/ObsPluginHelpers.cmake)
find_qt(VERSION ${QT_VERSION} COMPONENTS Widgets Core Gui)
+find_package(CURL REQUIRED)
if (WITH_DLIB_SUBMODULE)
set(CMAKE_POSITION_INDEPENDENT_CODE True)
@@ -92,6 +93,7 @@ set(PLUGIN_SOURCES
src/helper.cpp
src/ptz-backend.cpp
src/obsptz-backend.cpp
+ src/ptz-http-backend.cpp
src/dummy-backend.cpp
)
@@ -122,6 +124,7 @@ target_link_libraries(${CMAKE_PROJECT_NAME}
OBS::libobs
OBS::obs-frontend-api
dlib
+ CURL::libcurl
${plugin_additional_libs}
)
@@ -136,6 +139,7 @@ if(OS_WINDOWS)
add_definitions("-D_USE_MATH_DEFINES")
add_definitions("-D_CRT_SECURE_NO_WARNINGS") # to avoid a warning for `fopen`
add_definitions("-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR") # TODO: Remove once requiring OBS 30.2 or later.
+ target_compile_definitions(${PROJECT_NAME} PRIVATE NOMINMAX)
endif()
target_link_libraries(${CMAKE_PROJECT_NAME} OBS::w32-pthreads)
diff --git a/ci/plugin.spec b/ci/plugin.spec
index 44ea9fa..3f82886 100644
--- a/ci/plugin.spec
+++ b/ci/plugin.spec
@@ -13,6 +13,7 @@ BuildRequires: obs-studio-devel
BuildRequires: qt6-qtbase-devel qt6-qtbase-private-devel
BuildRequires: dlib-devel ffmpeg-free-devel sqlite-devel blas-devel lapack-devel
BuildRequires: flexiblas-devel
+BuildRequires: libcurl-devel
# dlib-devel requires /usr/include/ffmpeg so that install ffmpeg-free-devel
%package data
@@ -68,6 +69,7 @@ mv %{buildroot}/%{_datadir}/obs/obs-plugins/@PLUGIN_NAME@/LICENSE-shape_predicto
%files
%{_libdir}/obs-plugins/@PLUGIN_NAME@.so
%{_datadir}/obs/obs-plugins/@PLUGIN_NAME@/locale/
+%{_datadir}/obs/obs-plugins/@PLUGIN_NAME@/ptz/
%{_datadir}/licenses/%{name}/*
%files data
diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini
index 0148cfa..bd11b0f 100644
--- a/data/locale/en-US.ini
+++ b/data/locale/en-US.ini
@@ -1,2 +1,7 @@
Detector.dlib.hog="HOG, dlib"
Detector.dlib.cnn="CNN, dlib"
+Prop.ptz.http.host="Host name"
+Prop.ptz.http.host.desc="Host name and optionally port number, eg. 192.0.2.101:8080"
+Prop.ptz.http.user="User name"
+Prop.ptz.http.user.desc="Set if authentication is required. Leave blank if necessary."
+Prop.ptz.http.passwd="Password"
diff --git a/data/ptz/ptz-http-backend-camera-models.json b/data/ptz/ptz-http-backend-camera-models.json
new file mode 100644
index 0000000..1bf5822
--- /dev/null
+++ b/data/ptz/ptz-http-backend-camera-models.json
@@ -0,0 +1,25 @@
+{
+ "camera-models": [
+ {
+ "id": "hikvision",
+ "name": "HikVision PTZ (HTTP)",
+ "properties": {
+ },
+ "settings": {
+ "ptz-method": "PUT",
+ "ptz-url": "http://{host}/ISAPI/PTZCtrl/channels/1/continuous",
+ "ptz-payload": "{p}{t}{z}"
+ },
+ "control-function": {
+ "p": { "type": "linear-int", "k1": 1.0, "k0": 0, "max": 15 },
+ "t": { "type": "linear-int", "k1": 1.0, "k0": 0, "max": 15 },
+ "z": { "type": "linear-int", "k1": 1.0, "k0": 0, "max": 3 },
+ "TODO": "\"max\" should be configurable."
+ }
+ },
+ {
+ "id": "sony-srg300se",
+ "name": "Sony SRG-300SE (HTTP)"
+ }
+ ]
+}
diff --git a/src/face-tracker-ptz.cpp b/src/face-tracker-ptz.cpp
index a6d5d72..627b65f 100644
--- a/src/face-tracker-ptz.cpp
+++ b/src/face-tracker-ptz.cpp
@@ -17,6 +17,7 @@
#ifdef WITH_PTZ_TCP
#include "libvisca-thread.hpp"
#endif
+#include "ptz-http-backend.hpp"
#include "dummy-backend.hpp"
#define PTZ_MAX_X 0x18
@@ -107,6 +108,7 @@ static const struct ptz_backend_type_s backends[] =
#ifdef WITH_PTZ_TCP
BACKEND("visca-over-tcp", libvisca_thread),
#endif // WITH_PTZ_TCP
+ BACKEND("http", ptz_http_backend),
BACKEND("dummy", dummy_backend),
{NULL, NULL, NULL}
#undef BACKEND
@@ -452,6 +454,7 @@ static obs_properties_t *ftptz_properties(void *data)
#ifdef WITH_PTZ_TCP
obs_property_list_add_string(p, obs_module_text("VISCA over TCP"), "visca-over-tcp");
#endif // WITH_PTZ_TCP
+ obs_property_list_add_string(p, obs_module_text("HTTP"), "http");
obs_property_set_modified_callback(p, ptz_type_modified);
obs_properties_add_bool(pp, "invert_x", obs_module_text("Invert control (Pan)"));
diff --git a/src/ptz-http-backend.cpp b/src/ptz-http-backend.cpp
new file mode 100644
index 0000000..b15644c
--- /dev/null
+++ b/src/ptz-http-backend.cpp
@@ -0,0 +1,494 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "plugin-macros.generated.h"
+#include "ptz-http-backend.hpp"
+
+#define CAMERA_MODELS_FILE "ptz/ptz-http-backend-camera-models.json"
+
+static obs_data_array_t *get_camera_models()
+{
+ BPtr path = obs_module_file(CAMERA_MODELS_FILE);
+ OBSDataAutoRelease root = obs_data_create_from_json_file(path);
+ return obs_data_get_array(root, "camera-models");
+}
+
+static obs_data_t *get_camera_model(const char *ptz_http_id)
+{
+ if (!ptz_http_id)
+ return nullptr;
+
+ OBSDataArrayAutoRelease cameras = get_camera_models();
+ for (size_t i = 0, n = obs_data_array_count(cameras); i < n; i++) {
+ obs_data_t *camera = obs_data_array_item(cameras, i);
+ const char *id = obs_data_get_string(camera, "id");
+
+ if (strcmp(id, ptz_http_id) == 0)
+ return camera;
+
+ obs_data_release(camera);
+ }
+
+ return nullptr;
+}
+
+static void append_data_value(std::string &ret, obs_data *data, const char *name)
+{
+ char buf[64] = {0};
+ obs_data_item_t *item = obs_data_item_byname(data, name);
+ if (!item)
+ return;
+
+ switch (obs_data_item_gettype(item)) {
+ case OBS_DATA_STRING:
+ ret += obs_data_item_get_string(item);
+ break;
+ case OBS_DATA_NUMBER:
+ switch (obs_data_item_numtype(item)) {
+ case OBS_DATA_NUM_INT:
+ snprintf(buf, sizeof(buf) - 1, "%lld", obs_data_item_get_int(item));
+ ret += buf;
+ break;
+ case OBS_DATA_NUM_DOUBLE:
+ snprintf(buf, sizeof(buf) - 1, "%f", obs_data_item_get_double(item));
+ ret += buf;
+ break;
+ case OBS_DATA_NUM_INVALID:
+ break;
+ }
+ break;
+ default:
+ blog(LOG_ERROR, "Cannot convert camera settings '%s'", name);
+ }
+
+ obs_data_item_release(&item);
+}
+
+static std::string replace_placeholder(const char *str, obs_data *data)
+{
+ /**
+ * Replaces `{name}` in `str` with the actual value in `data`.
+ * Also replaces `{{}` in `str` with `{` as an escape.
+ */
+
+ std::string ret;
+
+ while (*str) {
+ if (strncmp(str, "{{}", 3) == 0) {
+ ret += '{';
+ str += 3;
+ }
+ else if (*str == '{') {
+ str++;
+ int end;
+ for (end = 0; str[end] && str[end] != '}'; end++);
+ if (str[end] == '}') {
+ std::string name(str, str + end);
+ append_data_value(ret, data, name.c_str());
+ str += end + 1;
+ }
+ }
+ else
+ ret += *str++;
+ }
+
+ return ret;
+}
+
+struct control_change_s
+{
+ int u_int = 0;
+ bool is_int = false;
+
+ bool update(float u, obs_data_t *control_function, const char *name);
+};
+
+bool control_change_s::update(float u, obs_data_t *control_function, const char *name)
+{
+ OBSDataAutoRelease func = obs_data_get_obj(control_function, name);
+ const char *type = obs_data_get_string(func, "type");
+ if (strcmp(type, "linear-int") == 0) {
+ double k1 = obs_data_get_double(func, "k1");
+ double k0 = obs_data_get_double(func, "k0");
+ int max = (int)obs_data_get_int(func, "max");
+ int u_int_next = (int)(k1 * u + k0);
+ if (u_int_next > max)
+ u_int_next = max;
+ else if (u_int_next < -max)
+ u_int_next = -max;
+
+ if (!is_int || u_int_next != u_int) {
+ is_int = true;
+ u_int = u_int_next;
+
+ return true;
+ }
+ return false;
+ }
+
+ return false;
+}
+
+static void add_control_value(obs_data_t *data, const char *name, control_change_s &u)
+{
+ if (u.is_int) {
+ obs_data_set_int(data, name, u.u_int);
+ } else {
+ blog(LOG_WARNING, "control data for '%s' is not defined", name);
+ }
+}
+
+struct ptz_http_backend_data_s
+{
+ std::mutex mutex;
+
+ /* User input data such as
+ * "id"
+ * "host"
+ */
+ OBSData user_data;
+
+ std::atomic data_changed;
+ std::atomic preset_changed;
+ std::atomic p_next, t_next, z_next;
+};
+
+ptz_http_backend::ptz_http_backend()
+{
+ data = new ptz_http_backend_data_s;
+
+ add_ref();
+ pthread_t thread;
+ pthread_create(&thread, NULL, ptz_http_backend::thread_main, (void*)this);
+ pthread_detach(thread);
+}
+
+ptz_http_backend::~ptz_http_backend()
+{
+ delete data;
+}
+
+void *ptz_http_backend::thread_main(void *data)
+{
+ auto *p = (ptz_http_backend*)data;
+ p->thread_loop();
+ p->release();
+ return NULL;
+}
+
+struct read_cb_data
+{
+ const char *data;
+ size_t offset;
+ size_t size;
+};
+
+static size_t read_cb(char *ptr, size_t size, size_t nmemb, void *userdata)
+{
+ auto *ctx = static_cast(userdata);
+
+ if (ctx->offset > ctx->size)
+ return 0;
+ size_t ret = std::min(nmemb, (ctx->size - ctx->offset) / size);
+
+ memcpy(ptr, ctx->data + ctx->offset, ret * size);
+ ctx->offset += ret * size;
+
+ return ret;
+}
+
+static int seek_cb(void *userdata, curl_off_t offset, int origin)
+{
+ auto *ctx = static_cast(userdata);
+
+ switch (origin) {
+ case SEEK_SET:
+ ctx->offset = offset;
+ break;
+ case SEEK_CUR:
+ ctx->offset += offset;
+ break;
+ case SEEK_END:
+ ctx->offset = ctx->size + offset;
+ break;
+ default:
+ return CURL_SEEKFUNC_FAIL;
+ }
+
+ return CURL_SEEKFUNC_OK;
+}
+
+static void call_url(obs_data_t *data, const char *method, const char *url, const char *payload)
+{
+ blog(LOG_DEBUG, "call_url(method='%s', url='%s', payload='%s')", method, url, payload);
+
+ struct read_cb_data read_cb_data = {
+ .data = payload,
+ .offset = 0,
+ .size = strlen(payload),
+ };
+
+ CURL *const c = curl_easy_init();
+ if (!c)
+ return;
+
+ curl_easy_setopt(c, CURLOPT_URL, url);
+
+ const char *user = obs_data_get_string(data, "user");
+ const char *passwd = obs_data_get_string(data, "passwd");
+ if (user && passwd && *user && *passwd) {
+ curl_easy_setopt(c, CURLOPT_HTTPAUTH, CURLAUTH_ANY);
+ std::string up = user;
+ up += ":";
+ up += passwd;
+ curl_easy_setopt(c, CURLOPT_USERPWD, up.c_str());
+ }
+
+ if (strcmp(method, "PUT") == 0) {
+ curl_easy_setopt(c, CURLOPT_UPLOAD, 1L);
+ curl_easy_setopt(c, CURLOPT_INFILESIZE_LARGE, (curl_off_t)read_cb_data.size);
+
+ curl_easy_setopt(c, CURLOPT_READFUNCTION, read_cb);
+ curl_easy_setopt(c, CURLOPT_READDATA, &read_cb_data);
+ curl_easy_setopt(c, CURLOPT_SEEKFUNCTION, seek_cb);
+ curl_easy_setopt(c, CURLOPT_SEEKDATA, &read_cb_data);
+ }
+
+ char error[CURL_ERROR_SIZE];
+ curl_easy_setopt(c, CURLOPT_ERRORBUFFER, error);
+
+ CURLcode code = curl_easy_perform(c);
+ if (code != CURLE_OK) {
+ blog(LOG_WARNING, "Failed method='%s' url='%s' %s",
+ method, url,
+ strlen(error) ? error : curl_easy_strerror(code));
+ }
+
+ curl_easy_cleanup(c);
+}
+
+static bool send_ptz(obs_data_t *user_data, obs_data_t *camera_settings)
+{
+ const char *method = obs_data_get_string(camera_settings, "ptz-method");
+ const char *url_t = obs_data_get_string(camera_settings, "ptz-url");
+ const char *payload_t = obs_data_get_string(camera_settings, "ptz-payload");
+ if (!method || !url_t)
+ return false;
+
+ std::string url = replace_placeholder(url_t, user_data);
+ std::string payload = replace_placeholder(payload_t, user_data);
+
+ call_url(user_data, method, url.c_str(), payload.c_str());
+
+ return true;
+}
+
+void ptz_http_backend::thread_loop()
+{
+ bool p_changed = true, t_changed = true, z_changed = true;
+ control_change_s up, ut, uz;
+
+ OBSData user_data;
+ BPtr ptz_http_id;
+
+ /* Camera settings such as
+ * "ptz-method"
+ * "ptz-url" and "ptz-payload"
+ */
+ OBSDataAutoRelease camera_settings;
+
+ /* Camera control function including these objects.
+ * "p", "t", "z"
+ */
+ OBSDataAutoRelease control_function;
+
+ while (get_ref() > 1) {
+ if (data->data_changed.exchange(false)) {
+ {
+ std::lock_guard lock(data->mutex);
+ /* Assuming `data->user_data` won't be touched by the other thread. */
+ user_data = data->user_data.Get();
+ }
+
+ const char *ptz_http_id_new = obs_data_get_string(user_data, "id");
+ if (!ptz_http_id_new)
+ continue;
+ if (!ptz_http_id || strcmp(ptz_http_id_new, ptz_http_id) != 0) {
+ ptz_http_id = bstrdup(ptz_http_id_new);
+ OBSDataAutoRelease camera = get_camera_model(ptz_http_id_new);
+ if (!camera) {
+ blog(LOG_ERROR, "Camera model for '%s' was not found", ptz_http_id.Get());
+ continue;
+ }
+
+ camera_settings = obs_data_get_obj(camera, "settings");
+ control_function = obs_data_get_obj(camera, "control-function");
+ }
+ }
+
+ if (!user_data || !ptz_http_id || !camera_settings || !control_function) {
+ os_sleep_ms(500);
+ continue;
+ }
+
+ p_changed |= up.update(data->p_next, control_function, "p");
+ t_changed |= ut.update(data->t_next, control_function, "t");
+ z_changed |= uz.update(data->z_next, control_function, "z");
+
+ add_control_value(user_data, "p", up);
+ add_control_value(user_data, "t", ut);
+ add_control_value(user_data, "z", uz);
+
+ if (p_changed || t_changed || z_changed) {
+ if (send_ptz(user_data, camera_settings)) {
+ p_changed = t_changed = z_changed = false;
+ }
+ }
+
+ // TODO: If send_ptz failed, try other variant such as send_pt and send_z.
+ }
+}
+
+void ptz_http_backend::set_config(struct obs_data *user_data)
+{
+ std::lock_guard lock(data->mutex);
+ data->user_data = user_data;
+ data->data_changed = true;
+}
+
+void ptz_http_backend::set_pantiltzoom_speed(float pan, float tilt, float zoom)
+{
+ data->p_next = pan;
+ data->t_next = tilt;
+ data->z_next = zoom;
+}
+
+static bool remove_id_specific_props(obs_properties_t *group)
+{
+ std::vector names;
+
+ for (obs_property_t *prop = obs_properties_first(group); prop; obs_property_next(&prop)) {
+ const char *name = obs_property_name(prop);
+ if (strncmp(name, "ptz.http.", 9) != 0)
+ continue;
+ if (strcmp(name, "ptz.http.id") == 0)
+ continue;
+ if (strcmp(name, "ptz.http.host") == 0)
+ continue;
+ if (strcmp(name, "ptz.http.user") == 0)
+ continue;
+ if (strcmp(name, "ptz.http.passwd") == 0)
+ continue;
+ names.push_back(name);
+ }
+
+ for (const char *name : names) {
+ obs_properties_remove_by_name(group, name);
+ }
+
+ return names.size() > 0;
+}
+
+static bool add_id_specific_props(obs_properties_t *group, const char *ptz_http_id)
+{
+ if (!ptz_http_id)
+ return false;
+
+ OBSDataAutoRelease camera = get_camera_model(ptz_http_id);
+ if (!camera) {
+ blog(LOG_ERROR, "Cannot find camera model '%s'", ptz_http_id);
+ return false;
+ }
+
+ bool modified = false;
+
+ OBSDataAutoRelease properties = obs_data_get_obj(camera, "properties");
+ for (obs_data_item_t *item = obs_data_first(properties); item; obs_data_item_next(&item)) {
+ const char *name = obs_data_item_get_name(item);
+ OBSDataAutoRelease obj = obs_data_item_get_obj(item);
+ const char *type = obs_data_get_string(obj, "type");
+ const char *description = obs_data_get_string(obj, "description");
+ const char *long_description = obs_data_get_string(obj, "long-description");
+ obs_property_t *prop;
+
+ if (!type || !description) {
+ blog(LOG_ERROR, "camera model '%s' has invalid property '%s'", ptz_http_id, name);
+ continue;
+ }
+
+ std::string prop_name = "ptz.http.";
+ prop_name += ptz_http_id;
+ prop_name += ".";
+ prop_name += name;
+
+ if (strcmp(type, "string") == 0) {
+ prop = obs_properties_add_text(group, prop_name.c_str(), description, OBS_TEXT_DEFAULT);
+ }
+ else {
+ blog(LOG_ERROR, "camera model '%s': property '%s': invalid type '%s'", ptz_http_id, name, type);
+ continue;
+ }
+
+ modified = true;
+
+ if (long_description)
+ obs_property_set_long_description(prop, long_description);
+ }
+
+ return modified;
+}
+
+static bool id_modified(obs_properties_t *props, obs_property_t *, obs_data_t *settings)
+{
+ bool modified = false;
+ obs_property_t *group_prop = obs_properties_get(props, "output");
+ obs_properties_t *group = obs_property_group_content(group_prop);
+
+ modified |= remove_id_specific_props(group);
+
+ modified |= add_id_specific_props(group, obs_data_get_string(settings, "ptz.http.id"));
+
+ return modified;
+}
+
+static void init_ptz_http_id(obs_properties_t *group_output, obs_property_t *prop, obs_data_t *settings)
+{
+ obs_property_set_modified_callback(prop, id_modified);
+
+ OBSDataArrayAutoRelease cameras = get_camera_models();
+ for (size_t i = 0, n = obs_data_array_count(cameras); i < n; i++) {
+ OBSDataAutoRelease camera = obs_data_array_item(cameras, i);
+
+ const char *id = obs_data_get_string(camera, "id");
+ const char *name = obs_data_get_string(camera, "name");
+
+ obs_property_list_add_string(prop, name, id);
+ }
+
+ if (obs_data_has_user_value(settings, "ptz.http.id"))
+ id_modified(obs_properties_get_parent(group_output), prop, settings);
+}
+
+bool ptz_http_backend::ptz_type_modified(obs_properties_t *group_output, obs_data_t *settings)
+{
+ if (obs_properties_get(group_output, "ptz.http.id"))
+ return false;
+
+ obs_property_t *prop = obs_properties_add_list(group_output, "ptz.http.id", obs_module_text("Camera Model"),
+ OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING);
+ init_ptz_http_id(group_output, prop, settings);
+
+ prop = obs_properties_add_text(group_output, "ptz.http.host", obs_module_text("Prop.ptz.http.host"), OBS_TEXT_DEFAULT);
+ obs_property_set_long_description(prop, obs_module_text("Prop.ptz.http.host.desc"));
+
+ prop = obs_properties_add_text(group_output, "ptz.http.user", obs_module_text("Prop.ptz.http.user"), OBS_TEXT_DEFAULT);
+ obs_property_set_long_description(prop, obs_module_text("Prop.ptz.http.user.desc"));
+
+ obs_properties_add_text(group_output, "ptz.http.passwd", obs_module_text("Prop.ptz.http.passwd"), OBS_TEXT_PASSWORD);
+
+ return true;
+}
diff --git a/src/ptz-http-backend.hpp b/src/ptz-http-backend.hpp
new file mode 100644
index 0000000..a578a4a
--- /dev/null
+++ b/src/ptz-http-backend.hpp
@@ -0,0 +1,31 @@
+#pragma once
+
+#include
+#include
+#include "ptz-backend.hpp"
+
+class ptz_http_backend : public ptz_backend
+{
+ struct ptz_http_backend_data_s *data;
+
+ static void *thread_main(void *);
+ void thread_loop();
+
+public:
+ ptz_http_backend();
+ ~ptz_http_backend() override;
+
+ void set_config(struct obs_data *data) override;
+
+ void set_pantilt_speed(int, int) override { }
+ void set_zoom_speed(int) override { }
+ void set_pantiltzoom_speed(float pan, float tilt, float zoom) override;
+ void recall_preset(int) override { }
+ float get_zoom() override {
+ // TODO: Implement if available
+ return 1.0f;
+ }
+
+public:
+ static bool ptz_type_modified(obs_properties_t *group_output, obs_data_t *settings);
+};