diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c6e66a..51abd9a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,9 @@ target_sources( src/ui/RequestBuilder.cpp src/ui/text-render-helper.cpp src/ui/outputmapping.cpp + src/ui/InputsDialog.cpp + src/ui/InputWidget.cpp + src/ui/obs-ui-utils.cpp src/url-source-callbacks.cpp src/url-source-info.c src/url-source-thread.cpp diff --git a/buildspec.json b/buildspec.json index e456035..ff8b0b8 100644 --- a/buildspec.json +++ b/buildspec.json @@ -45,7 +45,7 @@ } }, "name": "obs-urlsource", - "version": "0.3.1", + "version": "0.3.2", "author": "Roy Shilkrot", "website": "https://github.com/occ-ai/obs-urlsource", "email": "roy.shil@gmail.com", diff --git a/src/mapping-data.cpp b/src/mapping-data.cpp index 9a5418b..253a0a5 100644 --- a/src/mapping-data.cpp +++ b/src/mapping-data.cpp @@ -32,3 +32,36 @@ output_mapping_data deserialize_output_mapping_data(const std::string &data) } return result; } + +nlohmann::json serialize_input_mapping_data(const inputs_data &data) +{ + nlohmann::json j; + for (const auto &input : data) { + nlohmann::json j_input; + j_input["source"] = input.source; + j_input["no_empty"] = input.no_empty; + j_input["no_same"] = input.no_same; + j_input["aggregate"] = input.aggregate; + j_input["agg_method"] = input.agg_method; + j_input["resize_method"] = input.resize_method; + j.push_back(j_input); + } + return j; +} + +inputs_data deserialize_input_mapping_data(const std::string &data) +{ + inputs_data result; + nlohmann::json j = nlohmann::json::parse(data); + for (const auto &j_input : j) { + input_data input; + input.source = j_input.value("source", ""); + input.no_empty = j_input.value("no_empty", false); + input.no_same = j_input.value("no_same", false); + input.aggregate = j_input.value("aggregate", false); + input.agg_method = j_input.value("agg_method", -1); + input.resize_method = j_input.value("resize_method", ""); + result.push_back(input); + } + return result; +} diff --git a/src/mapping-data.h b/src/mapping-data.h index 623b0d3..a21ced1 100644 --- a/src/mapping-data.h +++ b/src/mapping-data.h @@ -4,6 +4,8 @@ #include #include +#include + const std::string none_internal_rendering = "None / Internal rendering"; struct output_mapping { @@ -21,4 +23,21 @@ struct output_mapping_data { std::string serialize_output_mapping_data(const output_mapping_data &data); output_mapping_data deserialize_output_mapping_data(const std::string &data); +struct input_data { + std::string source; + bool no_empty = false; + bool no_same = false; + bool aggregate = false; + int agg_method = -1; + std::string resize_method; + std::string last_obs_text_source_value; + std::string aggregate_to_empty_buffer; + uint64_t agg_buffer_begin_ts; +}; + +typedef std::vector inputs_data; + +nlohmann::json serialize_input_mapping_data(const inputs_data &data); +inputs_data deserialize_input_mapping_data(const std::string &data); + #endif // MAPPING_DATA_H diff --git a/src/obs-source-util.cpp b/src/obs-source-util.cpp index a63595e..a9d72cb 100644 --- a/src/obs-source-util.cpp +++ b/src/obs-source-util.cpp @@ -131,3 +131,14 @@ std::string convert_rgba_buffer_to_png_base64(const std::vector &rgba, return escaped; } + +std::string get_source_name_without_prefix(const std::string &source_name) +{ + if (source_name.size() > 0 && source_name[0] == '(') { + size_t end = source_name.find(')'); + if (end != std::string::npos && end + 2 < source_name.size()) { + return source_name.substr(end + 2); + } + } + return source_name; +} diff --git a/src/obs-source-util.h b/src/obs-source-util.h index 4a0330c..4f2c480 100644 --- a/src/obs-source-util.h +++ b/src/obs-source-util.h @@ -47,4 +47,6 @@ inline bool is_valid_output_source_name(const char *output_source_name) strcmp(output_source_name, "(null)") != 0 && strcmp(output_source_name, "") != 0; } +std::string get_source_name_without_prefix(const std::string &source_name); + #endif // OBS_SOURCE_UTIL_H diff --git a/src/request-data.cpp b/src/request-data.cpp index 60fa89e..45ea249 100644 --- a/src/request-data.cpp +++ b/src/request-data.cpp @@ -79,63 +79,60 @@ size_t header_callback(char *buffer, size_t size, size_t nitems, void *userdata) return real_size; } -void handle_nonempty_text(url_source_request_data *request_data, - request_data_handler_response &response, nlohmann::json &json, - const char *text) +void handle_nonempty_text(input_data &input, request_data_handler_response &response, + nlohmann::json &json, const char *text) { - if (request_data->obs_text_source_skip_if_same) { - if (request_data->last_obs_text_source_value == text) { + if (input.no_same) { + if (input.last_obs_text_source_value == text) { // Return an error response response.error_message = "OBS text source value is the same as last time, skipping was requested"; response.status_code = URL_SOURCE_REQUEST_BENIGN_ERROR_CODE; return; } - request_data->last_obs_text_source_value = text; + input.last_obs_text_source_value = text; } - if (request_data->aggregate_to_target != URL_SOURCE_AGG_TARGET_NONE) { + if (input.aggregate) { // aggregate to target is requested and the text is not empty // if the buffer ends with a punctuation mark, remove it - if (request_data->aggregate_to_empty_buffer.size() > 0) { - char lastChar = request_data->aggregate_to_empty_buffer.back(); + if (input.aggregate_to_empty_buffer.size() > 0) { + char lastChar = input.aggregate_to_empty_buffer.back(); if (lastChar == '.' || lastChar == ',' || lastChar == '!' || lastChar == '?') { - request_data->aggregate_to_empty_buffer.pop_back(); + input.aggregate_to_empty_buffer.pop_back(); // insert a space where the punctuation mark was - request_data->aggregate_to_empty_buffer += " "; + input.aggregate_to_empty_buffer += " "; } } // trim the text and add it to the aggregate buffer std::string textStr = text; - request_data->aggregate_to_empty_buffer += trim(textStr); + input.aggregate_to_empty_buffer += trim(textStr); // if the buffer is larger than the limit, remove the first part - if (request_data->aggregate_to_empty_buffer.size() > - URL_SOURCE_AGG_BUFFER_MAX_SIZE) { - request_data->aggregate_to_empty_buffer.erase( - 0, request_data->aggregate_to_empty_buffer.size() - + if (input.aggregate_to_empty_buffer.size() > URL_SOURCE_AGG_BUFFER_MAX_SIZE) { + input.aggregate_to_empty_buffer.erase( + 0, input.aggregate_to_empty_buffer.size() - URL_SOURCE_AGG_BUFFER_MAX_SIZE); } - if (request_data->aggregate_to_target != URL_SOURCE_AGG_TARGET_EMPTY) { + if (input.agg_method != URL_SOURCE_AGG_TARGET_EMPTY) { // this is a non-empty aggregate *timed* target - if (request_data->agg_buffer_begin_ts == 0) { + if (input.agg_buffer_begin_ts == 0) { // this is the first time we aggregate, set the timer - request_data->agg_buffer_begin_ts = get_time_ns(); + input.agg_buffer_begin_ts = get_time_ns(); } // check if the agg timer has expired - const uint64_t timer_interval_ns = url_source_agg_target_to_nanoseconds( - request_data->aggregate_to_target); - if ((get_time_ns() - request_data->agg_buffer_begin_ts) >= - timer_interval_ns) { + const uint64_t timer_interval_ns = + url_source_agg_target_to_nanoseconds(input.agg_method); + if ((get_time_ns() - input.agg_buffer_begin_ts) >= timer_interval_ns) { // aggregate timer has expired, use the aggregate buffer obs_log(LOG_INFO, "Aggregate timer expired, using aggregate buffer (len %d)", - request_data->aggregate_to_empty_buffer.size()); - json["input"] = request_data->aggregate_to_empty_buffer; - request_data->aggregate_to_empty_buffer = ""; - request_data->agg_buffer_begin_ts = get_time_ns(); + input.aggregate_to_empty_buffer.size()); + json["input"] = input.aggregate_to_empty_buffer; + input.aggregate_to_empty_buffer = ""; + input.agg_buffer_begin_ts = get_time_ns(); } else { // aggregate timer has not expired, return an error response response.error_message = @@ -149,24 +146,133 @@ void handle_nonempty_text(url_source_request_data *request_data, } } -void handle_empty_text(url_source_request_data *request_data, - request_data_handler_response &response, nlohmann::json &json) +void handle_empty_text(input_data &input, request_data_handler_response &response, + nlohmann::json &json) { - if (request_data->aggregate_to_target == URL_SOURCE_AGG_TARGET_EMPTY && - !request_data->aggregate_to_empty_buffer.empty()) { + if (input.agg_method == URL_SOURCE_AGG_TARGET_EMPTY && + !input.aggregate_to_empty_buffer.empty()) { // empty input found, use the aggregate buffer if it's not empty obs_log(LOG_INFO, "OBS text source is empty, using aggregate buffer (len %d)", - request_data->aggregate_to_empty_buffer.size()); - json["input"] = request_data->aggregate_to_empty_buffer; - request_data->aggregate_to_empty_buffer = ""; - request_data->agg_buffer_begin_ts = get_time_ns(); - } else if (request_data->obs_text_source_skip_if_empty) { + input.aggregate_to_empty_buffer.size()); + json["input"] = input.aggregate_to_empty_buffer; + input.aggregate_to_empty_buffer = ""; + input.agg_buffer_begin_ts = get_time_ns(); + } else if (input.no_empty) { // Return an error response response.error_message = "OBS text source is empty, skipping was requested"; response.status_code = URL_SOURCE_REQUEST_BENIGN_ERROR_CODE; } } +void put_inputs_on_json(url_source_request_data *request_data, CURL *curl, + request_data_handler_response &response, nlohmann::json &json) +{ + for (size_t i = 0; i < request_data->inputs.size(); i++) { + // Add the input to the json object + input_data &input = request_data->inputs[i]; + + if (input.source == "") { + // no dynamic data source is set + continue; + } + + // remove the prefix from the source name + std::string source_name = get_source_name_without_prefix(input.source); + // Check if the source is a text source + if (is_obs_source_text(source_name)) { + // Get text from OBS text source + obs_data_t *sourceSettings = obs_source_get_settings( + obs_get_source_by_name(source_name.c_str())); + const char *text = obs_data_get_string(sourceSettings, "text"); + obs_data_release(sourceSettings); + std::string textStr; + if (text != NULL) { + textStr = text; + } + + if (textStr.empty()) { + handle_empty_text(input, response, json); + } else { + // trim the text for whistespace + textStr = trim(textStr); + + handle_nonempty_text(input, response, json, textStr.c_str()); + + // if one of the headers is Content-Type application/json, make sure the text is JSONified + std::regex header_regex("content-type", + std::regex_constants::icase); + std::regex header_value_regex("application/json", + std::regex_constants::icase); + for (auto header : request_data->headers) { + // check if the header is Content-Type case insensitive using regex + if (std::regex_search(header.first, header_regex) && + std::regex_search(header.second, header_value_regex)) { + nlohmann::json tmp = text; + textStr = tmp.dump(); + // remove '"' from the beginning and end of the string + textStr = textStr.substr(1, textStr.size() - 2); + break; + } + } + + json["input" + std::to_string(i)] = textStr; + if (i == 0) { + // also add the 0'th input to the "input" key + json["input"] = textStr; + } + } + if (response.status_code == URL_SOURCE_REQUEST_BENIGN_ERROR_CODE) { + curl_easy_cleanup(curl); + return; + } + } else { + // this is not a text source. + // we should grab an image of its output, encode it to base64 and use it as input + obs_source_t *source = obs_get_source_by_name(source_name.c_str()); + if (source == NULL) { + obs_log(LOG_INFO, "Failed to get source by name"); + // Return an error response + response.error_message = "Failed to get source by name"; + response.status_code = URL_SOURCE_REQUEST_STANDARD_ERROR_CODE; + curl_easy_cleanup(curl); + return; + } + + // render the source to an image using get_rgba_from_source_render + source_render_data tf; + init_source_render_data(&tf); + // get the scale factor from the request_data->obs_input_source_resize_option + float scale = 1.0; + if (input.resize_method != "100%") { + // parse the scale from the string + std::string scaleStr = input.resize_method; + scaleStr.erase(std::remove(scaleStr.begin(), scaleStr.end(), '%'), + scaleStr.end()); + scale = (float)(std::stof(scaleStr) / 100.0f); + } + + uint32_t width, height; + std::vector rgba = + get_rgba_from_source_render(source, &tf, width, height, scale); + if (rgba.empty()) { + obs_log(LOG_INFO, "Failed to get RGBA from source render"); + // Return an error response + response.error_message = "Failed to get RGBA from source render"; + response.status_code = URL_SOURCE_REQUEST_STANDARD_ERROR_CODE; + curl_easy_cleanup(curl); + return; + } + destroy_source_render_data(&tf); + + // encode the image to base64 + std::string base64 = convert_rgba_buffer_to_png_base64(rgba, width, height); + + // set the input to the base64 encoded image + json["imageb64"] = base64; + } // end of non-text source + } +} + struct request_data_handler_response request_data_handler(url_source_request_data *request_data) { struct request_data_handler_response response; @@ -242,107 +348,15 @@ struct request_data_handler_response request_data_handler(url_source_request_dat nlohmann::json json; // json object or variables for inja - // If dynamic data source is set, replace the {input} placeholder with the source text - if (request_data->obs_text_source != "") { - // Check if the source is a text source - if (is_obs_source_text(request_data->obs_text_source)) { - // Get text from OBS text source - obs_data_t *sourceSettings = - obs_source_get_settings(obs_get_source_by_name( - request_data->obs_text_source.c_str())); - const char *text = obs_data_get_string(sourceSettings, "text"); - obs_data_release(sourceSettings); - std::string textStr; - if (text != NULL) { - textStr = text; - } + // Put the request inputs on the json object + put_inputs_on_json(request_data, curl, response, json); - if (textStr.empty()) { - handle_empty_text(request_data, response, json); - } else { - // trim the text for whistespace - textStr = trim(textStr); - - handle_nonempty_text(request_data, response, json, - textStr.c_str()); - - // if one of the headers is Content-Type application/json, make sure the text is JSONified - std::regex header_regex("content-type", - std::regex_constants::icase); - for (auto header : request_data->headers) { - // check if the header is Content-Type case insensitive using regex - if (std::regex_search(header.first, header_regex) && - header.second == "application/json") { - nlohmann::json tmp = text; - textStr = tmp.dump(); - // remove '"' from the beginning and end of the string - textStr = textStr.substr(1, textStr.size() - - 2); - break; - } - } - - json["input"] = textStr; - } - if (response.status_code == URL_SOURCE_REQUEST_BENIGN_ERROR_CODE) { - curl_easy_cleanup(curl); - return response; - } - } else { - // this is not a text source. - // we should grab an image of its output, encode it to base64 and use it as input - obs_source_t *source = obs_get_source_by_name( - request_data->obs_text_source.c_str()); - if (source == NULL) { - obs_log(LOG_INFO, "Failed to get source by name"); - // Return an error response - response.error_message = "Failed to get source by name"; - response.status_code = - URL_SOURCE_REQUEST_STANDARD_ERROR_CODE; - curl_easy_cleanup(curl); - return response; - } - - // render the source to an image using get_rgba_from_source_render - source_render_data tf; - init_source_render_data(&tf); - // get the scale factor from the request_data->obs_input_source_resize_option - float scale = 1.0; - if (request_data->obs_input_source_resize_option != "100%") { - // parse the scale from the string - std::string scaleStr = - request_data->obs_input_source_resize_option; - scaleStr.erase(std::remove(scaleStr.begin(), scaleStr.end(), - '%'), - scaleStr.end()); - scale = (float)(std::stof(scaleStr) / 100.0f); - } - - uint32_t width, height; - std::vector rgba = get_rgba_from_source_render( - source, &tf, width, height, scale); - if (rgba.empty()) { - obs_log(LOG_INFO, "Failed to get RGBA from source render"); - // Return an error response - response.error_message = - "Failed to get RGBA from source render"; - response.status_code = - URL_SOURCE_REQUEST_STANDARD_ERROR_CODE; - curl_easy_cleanup(curl); - return response; - } - destroy_source_render_data(&tf); - - // encode the image to base64 - std::string base64 = - convert_rgba_buffer_to_png_base64(rgba, width, height); - - // set the input to the base64 encoded image - json["imageb64"] = base64; - } // end of non-text source - } // end of dynamic data source != "" + if (response.status_code != URL_SOURCE_REQUEST_SUCCESS) { + curl_easy_cleanup(curl); + return response; + } - // Replace the {input} placeholder with the source text + // Replace placeholders in the URL and body with the input values inja::Environment env; // Add an inja callback for time formatting env.add_callback("strftime", 2, [](inja::Arguments &args) { @@ -528,11 +542,7 @@ std::string serialize_request_data(url_source_request_data *request_data) json["method"] = request_data->method; json["fail_on_http_error"] = request_data->fail_on_http_error; json["body"] = request_data->body; - json["obs_text_source"] = request_data->obs_text_source; - json["obs_text_source_skip_if_empty"] = request_data->obs_text_source_skip_if_empty; - json["obs_text_source_skip_if_same"] = request_data->obs_text_source_skip_if_same; - json["aggregate_to_target"] = request_data->aggregate_to_target; - json["obs_input_source_resize_option"] = request_data->obs_input_source_resize_option; + json["inputs"] = serialize_input_mapping_data(request_data->inputs); // SSL options json["ssl_client_cert_file"] = request_data->ssl_client_cert_file; json["ssl_client_key_file"] = request_data->ssl_client_key_file; @@ -578,15 +588,7 @@ url_source_request_data unserialize_request_data(std::string serialized_request_ request_data.method = json["method"].get(); request_data.fail_on_http_error = json.value("fail_on_http_error", false); request_data.body = json["body"].get(); - request_data.obs_text_source = json.value("obs_text_source", ""); - request_data.obs_text_source_skip_if_empty = - json.value("obs_text_source_skip_if_empty", false); - request_data.obs_text_source_skip_if_same = - json.value("obs_text_source_skip_if_same", false); - request_data.aggregate_to_target = - json.value("aggregate_to_target", URL_SOURCE_AGG_TARGET_NONE); - request_data.obs_input_source_resize_option = - json.value("obs_input_source_resize_option", "100%"); + request_data.inputs = deserialize_input_mapping_data(json["inputs"].dump()); // SSL options request_data.ssl_client_cert_file = json.value("ssl_client_cert_file", ""); diff --git a/src/request-data.h b/src/request-data.h index 9d3eb63..ac11339 100644 --- a/src/request-data.h +++ b/src/request-data.h @@ -8,6 +8,8 @@ #include +#include "mapping-data.h" + #define URL_SOURCE_REQUEST_STANDARD_ERROR_CODE -1 #define URL_SOURCE_REQUEST_BENIGN_ERROR_CODE -2 #define URL_SOURCE_REQUEST_PARSING_ERROR_CODE -3 @@ -88,16 +90,10 @@ struct url_source_request_data { std::string method; bool fail_on_http_error; std::string body; - std::string obs_text_source; - bool obs_text_source_skip_if_empty; - bool obs_text_source_skip_if_same; - int aggregate_to_target; - std::string aggregate_to_empty_buffer; - // agg buffer begin timestamp - uint64_t agg_buffer_begin_ts; + + inputs_data inputs; + uint64_t sequence_number; - std::string last_obs_text_source_value; - std::string obs_input_source_resize_option; // SSL options std::string ssl_client_cert_file; std::string ssl_client_key_file; @@ -130,15 +126,8 @@ struct url_source_request_data { method = std::string("GET"); fail_on_http_error = false; body = std::string(""); - obs_text_source = std::string(""); - obs_text_source_skip_if_empty = false; - obs_text_source_skip_if_same = false; - aggregate_to_target = URL_SOURCE_AGG_TARGET_NONE; - aggregate_to_empty_buffer = std::string(""); - agg_buffer_begin_ts = 0; + inputs = {}; sequence_number = 0; - last_obs_text_source_value = std::string(""); - obs_input_source_resize_option = std::string("100%"); ssl_verify_peer = false; headers = {}; output_type = std::string("text"); diff --git a/src/ui/InputWidget.cpp b/src/ui/InputWidget.cpp new file mode 100644 index 0000000..4dd1862 --- /dev/null +++ b/src/ui/InputWidget.cpp @@ -0,0 +1,81 @@ + +#include "InputWidget.h" +#include "plugin-support.h" +#include "ui_inputwidget.h" +#include "obs-ui-utils.h" +#include "obs-source-util.h" +#include "request-data.h" + +InputWidget::InputWidget(QWidget *parent) : QWidget(parent), ui(new Ui::InputWidget) +{ + ui->setupUi(this); + + // populate list of OBS text sources + obs_enum_sources(add_sources_to_combobox, ui->obsTextSourceComboBox); + + auto setObsTextSourceValueOptionsVisibility = [=]() { + // Hide the options if no OBS text source is selected + ui->widget_inputValueOptions->setEnabled( + ui->obsTextSourceComboBox->currentIndex() != 0); + // adjust the size of the dialog to fit the content + this->adjustSize(); + }; + connect(ui->obsTextSourceComboBox, &QComboBox::currentTextChanged, this, + setObsTextSourceValueOptionsVisibility); + + auto setAggTargetEnabled = [=]() { + ui->comboBox_aggTarget->setEnabled(ui->aggToTarget->isChecked()); + }; + connect(ui->aggToTarget, &QCheckBox::toggled, this, setAggTargetEnabled); + + auto inputSourceSelected = [=]() { + // if the source is a media source, show the resize option, otherwise hide it + auto current_data = ui->obsTextSourceComboBox->currentData(); + bool hide_resize_option = true; + if (current_data.isValid()) { + const std::string source_name = current_data.toString().toStdString(); + hide_resize_option = is_obs_source_text(source_name); + } + ui->comboBox_resizeInput->setVisible(!hide_resize_option); + ui->label_resizeInput->setVisible(!hide_resize_option); + }; + connect(ui->obsTextSourceComboBox, &QComboBox::currentTextChanged, this, + inputSourceSelected); +} + +InputWidget::~InputWidget() +{ + delete ui; +} + +void InputWidget::setInputData(const input_data &input) +{ + ui->obsTextSourceComboBox->setCurrentText(input.source.c_str()); + if (input.aggregate) { + ui->comboBox_aggTarget->setCurrentIndex(input.agg_method); + // enable ui->comboBox_aggTarget + ui->comboBox_aggTarget->setEnabled(true); + } + ui->aggToTarget->setChecked(input.aggregate); + ui->comboBox_resizeInput->setCurrentText(input.resize_method.c_str()); + ui->obsTextSourceEnabledCheckBox->setChecked(input.no_empty); + ui->obsTextSourceSkipSameCheckBox->setChecked(input.no_same); +} + +input_data InputWidget::getInputDataFromUI() +{ + input_data input; + + input.source = ui->obsTextSourceComboBox->currentText().toUtf8().constData(); + if (ui->aggToTarget->isChecked()) { + input.agg_method = ui->comboBox_aggTarget->currentIndex(); + } else { + input.agg_method = URL_SOURCE_AGG_TARGET_NONE; + } + input.aggregate = ui->aggToTarget->isChecked(); + input.resize_method = ui->comboBox_resizeInput->currentText().toUtf8().constData(); + input.no_empty = ui->obsTextSourceEnabledCheckBox->isChecked(); + input.no_same = ui->obsTextSourceSkipSameCheckBox->isChecked(); + + return input; +} diff --git a/src/ui/InputWidget.h b/src/ui/InputWidget.h new file mode 100644 index 0000000..ed4d63f --- /dev/null +++ b/src/ui/InputWidget.h @@ -0,0 +1,29 @@ +#ifndef INPUT_WIDGET_H +#define INPUT_WIDGET_H + +#include + +#include "mapping-data.h" + +namespace Ui { +class InputWidget; +} + +class InputWidget : public QWidget { + Q_OBJECT + +public: + explicit InputWidget(QWidget *parent = nullptr); + ~InputWidget(); + + InputWidget(const InputWidget &) = delete; + InputWidget &operator=(const InputWidget &) = delete; + + void setInputData(const input_data &data); + input_data getInputDataFromUI(); + +private: + Ui::InputWidget *ui; +}; + +#endif // INPUT_WIDGET_H diff --git a/src/ui/InputsDialog.cpp b/src/ui/InputsDialog.cpp new file mode 100644 index 0000000..4a14ae1 --- /dev/null +++ b/src/ui/InputsDialog.cpp @@ -0,0 +1,77 @@ +#include "InputsDialog.h" + +#include "plugin-support.h" +#include "ui_inputsdialog.h" +#include "mapping-data.h" +#include "obs-ui-utils.h" +#include "request-data.h" +#include "InputWidget.h" + +#include +#include + +InputsDialog::InputsDialog(QWidget *parent) : QDialog(parent), ui(new Ui::InputsDialog) +{ + ui->setupUi(this); + + connect(ui->toolButton_addinput, &QToolButton::clicked, this, &InputsDialog::addInput); + connect(ui->toolButton_removeinput, &QToolButton::clicked, this, + &InputsDialog::removeInput); +} + +InputsDialog::~InputsDialog() +{ + delete ui; +} + +void InputsDialog::addInput() +{ + // add a new Input widget to the tableView + InputWidget *widget = new InputWidget(ui->listWidget); + QListWidgetItem *item = new QListWidgetItem(ui->listWidget); + + item->setSizeHint(widget->sizeHint()); + ui->listWidget->setItemWidget(item, widget); + + // enable the remove button + ui->toolButton_removeinput->setEnabled(true); +} + +void InputsDialog::removeInput() +{ + // remove the selected Input widget from the tableView + QListWidgetItem *item = ui->listWidget->currentItem(); + if (item) { + delete item; + } + + // if there are no more items in the tableView, disable the remove button + if (ui->listWidget->count() == 0) { + ui->toolButton_removeinput->setEnabled(false); + } +} + +void InputsDialog::setInputsData(const inputs_data &data) +{ + for (const auto &input : data) { + addInput(); + QWidget *widget = ui->listWidget->itemWidget( + ui->listWidget->item(ui->listWidget->count() - 1)); + InputWidget *inputWidget = (InputWidget *)widget; + inputWidget->setInputData(input); + } +} + +inputs_data InputsDialog::getInputsDataFromUI() +{ + inputs_data data; + + for (int i = 0; i < ui->listWidget->count(); i++) { + QListWidgetItem *item = ui->listWidget->item(i); + QWidget *widget = ui->listWidget->itemWidget(item); + InputWidget *inputWidget = (InputWidget *)widget; + data.push_back(inputWidget->getInputDataFromUI()); + } + + return data; +} diff --git a/src/ui/InputsDialog.h b/src/ui/InputsDialog.h new file mode 100644 index 0000000..2ff98c3 --- /dev/null +++ b/src/ui/InputsDialog.h @@ -0,0 +1,36 @@ +#ifndef INPUTSDIALOG_H +#define INPUTSDIALOG_H + +#include +#include + +#include "mapping-data.h" + +namespace Ui { +class InputsDialog; +} + +class InputsDialog : public QDialog { + Q_OBJECT + +public: + explicit InputsDialog(QWidget *parent = nullptr); + ~InputsDialog(); + + InputsDialog(const InputsDialog &) = delete; + InputsDialog &operator=(const InputsDialog &) = delete; + + inputs_data getInputsDataFromUI(); + + // populate the listWidget with the inputs_data + void setInputsData(const inputs_data &data); + +private: + Ui::InputsDialog *ui; + +public slots: + void addInput(); + void removeInput(); +}; + +#endif // INPUTSDIALOG_H diff --git a/src/ui/RequestBuilder.cpp b/src/ui/RequestBuilder.cpp index 66a03b1..d3bdac8 100644 --- a/src/ui/RequestBuilder.cpp +++ b/src/ui/RequestBuilder.cpp @@ -3,6 +3,8 @@ #include "ui_requestbuilder.h" #include "CollapseButton.h" #include "plugin-support.h" +#include "InputsDialog.h" +#include "mapping-data.h" #include @@ -171,9 +173,7 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, } ] })"); - ui->obsTextSourceEnabledCheckBox->setChecked(true); - ui->obsTextSourceSkipSameCheckBox->setChecked(true); - ui->outputTypeComboBox->setCurrentIndex(3); + ui->outputTypeComboBox->setCurrentIndex(4); ui->outputJSONPathLineEdit->setText("$.choices.0.message.content"); } else if (index == 2) { /* ------------------------------------------- */ @@ -185,10 +185,8 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, "input": "{{input}}", "voice": "alloy" })"); - ui->obsTextSourceEnabledCheckBox->setChecked(true); - ui->obsTextSourceSkipSameCheckBox->setChecked(true); ui->sslOptionsCheckbox->setChecked(false); - ui->outputTypeComboBox->setCurrentIndex(2); + ui->outputTypeComboBox->setCurrentIndex(3); } else if (index == 3) { /* --------------------------------------------- */ /* --------------- OpenAI Vision --------------- */ @@ -215,9 +213,7 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, ], "max_tokens": 50 })"); - ui->obsTextSourceEnabledCheckBox->setChecked(true); - ui->obsTextSourceSkipSameCheckBox->setChecked(true); - ui->outputTypeComboBox->setCurrentIndex(3); + ui->outputTypeComboBox->setCurrentIndex(4); ui->outputJSONPathLineEdit->setText("$.choices.0.message.content"); } else if (index == 4) { /* ------------------------------------------- */ @@ -234,9 +230,7 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, "model_id": "eleven_monolingual_v1", "text": "{{input}}" })"); - ui->obsTextSourceEnabledCheckBox->setChecked(true); - ui->obsTextSourceSkipSameCheckBox->setChecked(true); - ui->outputTypeComboBox->setCurrentIndex(2); + ui->outputTypeComboBox->setCurrentIndex(3); } else if (index == 5) { /* --------------------------------------------- */ /* --------------- Google Sheets --------------- */ @@ -245,7 +239,7 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, addHeaders({}, ui->tableView_headers); ui->urlLineEdit->setText( "https://sheets.googleapis.com/v4/spreadsheets/$SHEET_ID$/values/$CELL_OR_RANGE$?key=$API_KEY$"); - ui->outputTypeComboBox->setCurrentIndex(3); + ui->outputTypeComboBox->setCurrentIndex(4); ui->outputJSONPathLineEdit->setText("$.values.0.0"); } else if (index == 6) { /* ----------------------------------------------- */ @@ -265,9 +259,7 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, ], "target_lang": "DE" })"); - ui->obsTextSourceEnabledCheckBox->setChecked(true); - ui->obsTextSourceSkipSameCheckBox->setChecked(true); - ui->outputTypeComboBox->setCurrentIndex(3); + ui->outputTypeComboBox->setCurrentIndex(4); ui->outputJSONPathLineEdit->setText("$.translations.0.text"); } else if (index == 7) { // Polyglot Translate @@ -275,8 +267,6 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, ui->urlLineEdit->setText("http://localhost:18080/translate"); ui->bodyTextEdit->setText( "{\"text\":\"{{input}}\", \"source_lang\":\"eng_Latn\", \"target_lang\":\"spa_Latn\"}"); - ui->obsTextSourceEnabledCheckBox->setChecked(true); - ui->obsTextSourceSkipSameCheckBox->setChecked(true); ui->sslOptionsCheckbox->setChecked(false); ui->outputTypeComboBox->setCurrentIndex(0); ui->outputRegexLineEdit->setText(""); @@ -292,8 +282,6 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, "http://upload.youtube.com/closedcaption?cid=xxxx-xxxx-xxxx-xxxx-xxxx&seq={{seq}}"); ui->bodyTextEdit->setText(R"({{strftime("%Y-%m-%dT%H:%M:%S.000", true)}} {{input}})"); - ui->obsTextSourceEnabledCheckBox->setChecked(true); - ui->obsTextSourceSkipSameCheckBox->setChecked(true); ui->sslOptionsCheckbox->setChecked(false); ui->outputTypeComboBox->setCurrentIndex(0); ui->outputRegexLineEdit->setText(""); @@ -316,6 +304,20 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, ui->tableView_headers->selectionModel()->currentIndex().row()); }); + connect(ui->pushButton_addInputs, &QPushButton::clicked, this, [=]() { + // open the inputs modal + InputsDialog inputsDialog(this); + inputsDialog.setInputsData(this->inputs_data_); + inputsDialog.exec(); + + if (inputsDialog.result() == QDialog::Accepted) { + // get the inputs data from the inputs modal + // add the inputs to the request_data + this->inputs_data_ = inputsDialog.getInputsDataFromUI(); + } + }); + this->inputs_data_ = request_data->inputs; + ui->sslCertFileLineEdit->setText( QString::fromStdString(request_data->ssl_client_cert_file)); ui->sslKeyFileLineEdit->setText(QString::fromStdString(request_data->ssl_client_key_file)); @@ -354,59 +356,6 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, } ui->sslOptionsGroup->setVisible(ui->sslOptionsCheckbox->isChecked()); - // populate list of OBS text sources - obs_enum_sources(add_sources_to_qcombobox, ui->obsTextSourceComboBox); - // Select the current OBS text source, if any - int itemIdx = ui->obsTextSourceComboBox->findData( - QVariant(QString::fromStdString(request_data->obs_text_source))); - if (itemIdx != -1) { - ui->obsTextSourceComboBox->setCurrentIndex(itemIdx); - } else { - ui->obsTextSourceComboBox->setCurrentIndex(0); - } - auto setObsTextSourceValueOptionsVisibility = [=]() { - // Hide the options if no OBS text source is selected - ui->widget_inputValueOptions->setEnabled( - ui->obsTextSourceComboBox->currentIndex() != 0); - // adjust the size of the dialog to fit the content - this->adjustSize(); - }; - setObsTextSourceValueOptionsVisibility(); - connect(ui->obsTextSourceComboBox, &QComboBox::currentTextChanged, this, - setObsTextSourceValueOptionsVisibility); - - ui->obsTextSourceEnabledCheckBox->setChecked(request_data->obs_text_source_skip_if_empty); - ui->obsTextSourceSkipSameCheckBox->setChecked(request_data->obs_text_source_skip_if_same); - ui->aggToTarget->setChecked(request_data->aggregate_to_target != - URL_SOURCE_AGG_TARGET_NONE); - ui->comboBox_aggTarget->setCurrentIndex( - ui->comboBox_aggTarget->findText(QString::fromStdString( - url_source_agg_target_to_string(request_data->aggregate_to_target)))); - - auto setAggTargetEnabled = [=]() { - ui->comboBox_aggTarget->setEnabled(ui->aggToTarget->isChecked()); - }; - setAggTargetEnabled(); - connect(ui->aggToTarget, &QCheckBox::toggled, this, setAggTargetEnabled); - - ui->comboBox_resizeInput->setCurrentText( - QString::fromStdString(request_data->obs_input_source_resize_option)); - - auto inputSourceSelected = [=]() { - // if the source is a media source, show the resize option, otherwise hide it - auto current_data = ui->obsTextSourceComboBox->currentData(); - bool hide_resize_option = true; - if (current_data.isValid()) { - const std::string source_name = current_data.toString().toStdString(); - hide_resize_option = is_obs_source_text(source_name); - } - ui->comboBox_resizeInput->setVisible(!hide_resize_option); - ui->label_resizeInput->setVisible(!hide_resize_option); - }; - connect(ui->obsTextSourceComboBox, &QComboBox::currentTextChanged, this, - inputSourceSelected); - inputSourceSelected(); - ui->bodyTextEdit->setText(QString::fromStdString(request_data->body)); auto setVisibilityOfBody = [=]() { @@ -520,23 +469,6 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, request_data_for_saving->fail_on_http_error = ui->checkBox_failonhttperrorcodes->isChecked(); request_data_for_saving->body = ui->bodyTextEdit->toPlainText().toStdString(); - if (ui->obsTextSourceComboBox->currentData().toString().toStdString() != "None") { - request_data_for_saving->obs_text_source = - ui->obsTextSourceComboBox->currentData().toString().toStdString(); - } else { - request_data_for_saving->obs_text_source = ""; - } - request_data_for_saving->obs_text_source_skip_if_empty = - ui->obsTextSourceEnabledCheckBox->isChecked(); - request_data_for_saving->obs_text_source_skip_if_same = - ui->obsTextSourceSkipSameCheckBox->isChecked(); - request_data_for_saving->aggregate_to_target = - ui->aggToTarget->isChecked() - ? url_source_agg_target_string_to_enum( - ui->comboBox_aggTarget->currentText().toStdString()) - : URL_SOURCE_AGG_TARGET_NONE; - request_data_for_saving->obs_input_source_resize_option = - ui->comboBox_resizeInput->currentText().toStdString(); // Save the SSL certificate file request_data_for_saving->ssl_client_cert_file = @@ -563,6 +495,8 @@ RequestBuilder::RequestBuilder(url_source_request_data *request_data, itemModel->item(i, 1)->text().toStdString())); } + request_data_for_saving->inputs = this->inputs_data_; + // Save the output parsing options request_data_for_saving->output_type = ui->outputTypeComboBox->currentText().toStdString(); diff --git a/src/ui/RequestBuilder.h b/src/ui/RequestBuilder.h index f45ebd0..3c5783e 100644 --- a/src/ui/RequestBuilder.h +++ b/src/ui/RequestBuilder.h @@ -1,6 +1,7 @@ #include #include "request-data.h" +#include "mapping-data.h" namespace Ui { class RequestBuilder; @@ -23,4 +24,5 @@ private slots: private: Ui::RequestBuilder *ui; + inputs_data inputs_data_; }; diff --git a/src/ui/inputsdialog.ui b/src/ui/inputsdialog.ui new file mode 100644 index 0000000..98a58f0 --- /dev/null +++ b/src/ui/inputsdialog.ui @@ -0,0 +1,123 @@ + + + InputsDialog + + + + 0 + 0 + 634 + 313 + + + + Inputs + + + + + + + 0 + 0 + + + + + 111 + 111 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + + false + + + + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + InputsDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + InputsDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/ui/inputwidget.ui b/src/ui/inputwidget.ui new file mode 100644 index 0000000..13f3016 --- /dev/null +++ b/src/ui/inputwidget.ui @@ -0,0 +1,212 @@ + + + InputWidget + + + + 0 + 0 + 545 + 100 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + "Select a OBS text source to use its current text in the querystring or request body as `{{input}}` or `{{imageb64}}`." + + + + None + + + + + + + + + 0 + 0 + + + + Resize + + + + + + + + 0 + 0 + + + + + 100% + + + + + 50% + + + + + 25% + + + + + 10% + + + + + 5% + + + + + + + + + + + false + + + + + + No empty + + + + + + + No same + + + + + + + Qt::Horizontal + + + + 181 + 20 + + + + + + + + Aggregate the input until empty is found, then use the aggregate value for the request. + + + Agg + + + + + + + to Empty + + + + to Empty + + + + + for 30s + + + + + for 1m + + + + + for 2m + + + + + for 5m + + + + + for 10m + + + + + + + + + + + + + + + diff --git a/src/ui/obs-ui-utils.cpp b/src/ui/obs-ui-utils.cpp new file mode 100644 index 0000000..35aa6fa --- /dev/null +++ b/src/ui/obs-ui-utils.cpp @@ -0,0 +1,34 @@ + +#include "obs-ui-utils.h" + +#include +#include + +#include + +// add_sources_to_list is a helper function that adds all text and media sources to the list +bool add_sources_to_combobox(void *list_property, obs_source_t *source) +{ + // add all text and media sources to the list + auto source_id = obs_source_get_id(source); + if (strcmp(source_id, "text_ft2_source_v2") != 0 && + strcmp(source_id, "text_gdiplus_v2") != 0 && strcmp(source_id, "ffmpeg_source") != 0 && + strcmp(source_id, "image_source") != 0) { + return true; + } + + QComboBox *sources = static_cast(list_property); + const char *name = obs_source_get_name(source); + std::string name_with_prefix; + // add a prefix to the name to indicate the source type + if (strcmp(source_id, "text_ft2_source_v2") == 0 || + strcmp(source_id, "text_gdiplus_v2") == 0) { + name_with_prefix = std::string("(Text) ").append(name); + } else if (strcmp(source_id, "image_source") == 0) { + name_with_prefix = std::string("(Image) ").append(name); + } else if (strcmp(source_id, "ffmpeg_source") == 0) { + name_with_prefix = std::string("(Media) ").append(name); + } + sources->addItem(name_with_prefix.c_str()); + return true; +} diff --git a/src/ui/obs-ui-utils.h b/src/ui/obs-ui-utils.h new file mode 100644 index 0000000..daafb30 --- /dev/null +++ b/src/ui/obs-ui-utils.h @@ -0,0 +1,8 @@ +#ifndef UI_OBS_UTILS_H +#define UI_OBS_UTILS_H + +#include + +bool add_sources_to_combobox(void *list_property, obs_source_t *source); + +#endif // UI_OBS_UTILS_H diff --git a/src/ui/outputmapping.cpp b/src/ui/outputmapping.cpp index 6d8e83b..3aa70ac 100644 --- a/src/ui/outputmapping.cpp +++ b/src/ui/outputmapping.cpp @@ -1,41 +1,13 @@ #include "plugin-support.h" #include "outputmapping.h" #include "ui_outputmapping.h" +#include "obs-ui-utils.h" #include #include #include -#include -#include - namespace { -// add_sources_to_list is a helper function that adds all text and media sources to the list -bool add_sources_to_combobox(void *list_property, obs_source_t *source) -{ - // add all text and media sources to the list - auto source_id = obs_source_get_id(source); - if (strcmp(source_id, "text_ft2_source_v2") != 0 && - strcmp(source_id, "text_gdiplus_v2") != 0 && strcmp(source_id, "ffmpeg_source") != 0 && - strcmp(source_id, "image_source") != 0) { - return true; - } - - QComboBox *sources = static_cast(list_property); - const char *name = obs_source_get_name(source); - std::string name_with_prefix; - // add a prefix to the name to indicate the source type - if (strcmp(source_id, "text_ft2_source_v2") == 0 || - strcmp(source_id, "text_gdiplus_v2") == 0) { - name_with_prefix = std::string("(Text) ").append(name); - } else if (strcmp(source_id, "image_source") == 0) { - name_with_prefix = std::string("(Image) ").append(name); - } else if (strcmp(source_id, "ffmpeg_source") == 0) { - name_with_prefix = std::string("(Media) ").append(name); - } - sources->addItem(name_with_prefix.c_str()); - return true; -} const std::string default_css_props = R"(background-color: transparent; color: #FFFFFF; diff --git a/src/ui/outputmapping.ui b/src/ui/outputmapping.ui index 7be141d..a1c2266 100644 --- a/src/ui/outputmapping.ui +++ b/src/ui/outputmapping.ui @@ -11,7 +11,7 @@ - Dialog + Output Mapping true diff --git a/src/ui/requestbuilder.ui b/src/ui/requestbuilder.ui index 61e6e73..2dcdef2 100644 --- a/src/ui/requestbuilder.ui +++ b/src/ui/requestbuilder.ui @@ -6,8 +6,8 @@ 0 0 - 493 - 931 + 622 + 1134 @@ -17,7 +17,7 @@ - Dialog + Request Builder @@ -367,203 +367,13 @@ - - - - 2 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 3 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - "Select a OBS text source to use its current text in the querystring or request body as `{{input}}` or `{{imageb64}}`." - - - - None - - - - - - - - - 0 - 0 - - - - Resize - - - - - - - - 0 - 0 - - - - - 100% - - - - - 50% - - - - - 25% - - - - - 10% - - - - - 5% - - - - - - - - - - - - 2 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - No empty - - - - - - - No same - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Aggregate the input until empty is found, then use the aggregate value for the request. - - - Agg - - - - - - - to Empty - - - - to Empty - - - - - for 30s - - - - - for 1m - - - - - for 2m - - - - - for 5m - - - - - for 10m - - - - - - - - + + + Add Inputs + - + <html><head/><body><p>Available functions: (for URL field above as well)</p><p>- {{input}} : The dynamic input</p><p>- {{strftime(&lt;format&gt;, &lt;utc? true/false&gt;)}} : Add a formatted time</p><p>- {{urlencode(var)}} : URL Encoded input</p><p>- {{imageb64}} : Base64 encoded image input</p><p>- {{seq}} : Sequential counter for requests</p></body></html> @@ -573,7 +383,7 @@ - + false @@ -583,21 +393,21 @@ - + SSL Options - + - +