From ba89b60550d01679fef20de50a3da3430ba0a014 Mon Sep 17 00:00:00 2001 From: rueger-events-bot <287312438+rueger-events-bot@users.noreply.github.com> Date: Sat, 23 May 2026 22:30:45 +0000 Subject: [PATCH 1/6] feat: add Marshall CV-370 day/night toggle --- CMakeLists.txt | 18 +++++ src/MainWindow.cpp | 10 +++ src/MarshallCv370Controller.cpp | 68 ++++++++++++++++++ src/MarshallCv370Controller.h | 23 ++++++ src/Project.cpp | 6 ++ src/Project.h | 3 + src/ui/StreamSourcePanel.cpp | 90 +++++++++++++++++++++++- src/ui/StreamSourcePanel.h | 5 +- tests/test_marshall_cv370_controller.cpp | 35 +++++++++ 9 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/MarshallCv370Controller.cpp create mode 100644 src/MarshallCv370Controller.h create mode 100644 tests/test_marshall_cv370_controller.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e55e5b7..2ba6958 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,7 @@ set(SOURCES src/MainWindow.cpp src/VideoWidget.cpp src/NdiReceiver.cpp + src/MarshallCv370Controller.cpp src/WebcamCapture.cpp src/DeckLinkCapture.cpp src/PsnSender.cpp @@ -68,6 +69,7 @@ set(HEADERS src/MainWindow.h src/VideoWidget.h src/NdiReceiver.h + src/MarshallCv370Controller.h src/WebcamCapture.h src/DeckLinkCapture.h src/PsnSender.h @@ -174,6 +176,22 @@ target_link_libraries(onpoint PRIVATE target_compile_definitions(onpoint PRIVATE WEBCAM_AVAILABLE=1) +include(CTest) +if(BUILD_TESTING) + find_package(Qt6 COMPONENTS Test QUIET) + if(TARGET Qt6::Test) + add_executable(onpoint_tests + tests/test_marshall_cv370_controller.cpp + src/MarshallCv370Controller.cpp + ) + target_include_directories(onpoint_tests PRIVATE src) + target_link_libraries(onpoint_tests PRIVATE Qt6::Core Qt6::Network Qt6::Test) + add_test(NAME marshall_cv370_controller COMMAND onpoint_tests) + else() + message(WARNING "Qt6 Test component not found; skipping unit tests") + endif() +endif() + if(NDI_FOUND) target_include_directories(onpoint PRIVATE "${NDI_INCLUDE_DIR}") target_link_libraries(onpoint PRIVATE "${NDI_LIB}") diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 2a4370f..f443123 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -196,6 +196,13 @@ MainWindow::MainWindow(NdiReceiver* ndi, QWidget* parent) : QMainWindow(parent) this, [this](const QString& id, const QString& conn, uint32_t mode, bool b10) { setDecklinkSource(id, conn, mode, b10); }); + connect(streamPanel_, &StreamSourcePanel::marshallCv370ConfigChanged, + this, [this](bool enabled, const QString& host, bool nightMode) { + project_.cv370Enabled = enabled; + project_.cv370Host = host; + project_.cv370NightMode = nightMode; + markDirty(); + }); connect(networkPanel_, &NetworkSettingsPanel::configChanged, this, [this](const NetworkConfig& cfg) { @@ -651,6 +658,9 @@ void MainWindow::applyProject() { psnReceiver_->stop(); psnReceiver_->wait(); } + streamPanel_->setMarshallCv370Config(project_.cv370Enabled, + project_.cv370Host, + project_.cv370NightMode); if (project_.videoSourceType == "decklink" && !project_.decklinkDevice.isEmpty()) { setDecklinkSource(project_.decklinkDevice, project_.decklinkConnection, project_.decklinkDisplayMode, project_.decklinkAllow10Bit); diff --git a/src/MarshallCv370Controller.cpp b/src/MarshallCv370Controller.cpp new file mode 100644 index 0000000..991550b --- /dev/null +++ b/src/MarshallCv370Controller.cpp @@ -0,0 +1,68 @@ +#include "MarshallCv370Controller.h" + +#include +#include +#include +#include +#include + +MarshallCv370Controller::MarshallCv370Controller(QObject* parent) + : QObject(parent), network_(new QNetworkAccessManager(this)) +{ +} + +QUrl MarshallCv370Controller::buildSetIrCutUrl(const QString& host, bool nightMode) { + QString normalized = host.trimmed(); + if (normalized.isEmpty()) + return {}; + + if (!normalized.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive) && + !normalized.startsWith(QStringLiteral("https://"), Qt::CaseInsensitive)) { + normalized.prepend(QStringLiteral("http://")); + } + + QUrl base(normalized); + if (!base.isValid() || base.host().isEmpty()) + return {}; + + base.setPath(QStringLiteral("/cgi-bin/web.fcgi")); + + QJsonObject image; + image[QStringLiteral("ircut")] = nightMode ? 0 : 1; + + QJsonObject payload; + payload[QStringLiteral("image")] = image; + + const QString compactJson = QString::fromUtf8( + QJsonDocument(payload).toJson(QJsonDocument::Compact)); + base.setQuery(QStringLiteral("func=set%1").arg(compactJson)); + return base; +} + +void MarshallCv370Controller::setNightMode(const QString& host, bool nightMode) { + const QUrl url = buildSetIrCutUrl(host, nightMode); + if (!url.isValid() || url.host().isEmpty()) { + emit requestFinished(false, nightMode, QStringLiteral("Enter a valid CV-370 host or IP address.")); + return; + } + + QNetworkRequest request(url); + auto* reply = network_->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply, nightMode]() { + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool transportOk = reply->error() == QNetworkReply::NoError; + const bool httpOk = status == 0 || (status >= 200 && status < 300); + const bool ok = transportOk && httpOk; + QString message; + if (ok) { + message = nightMode ? QStringLiteral("CV-370 switched to night mode.") + : QStringLiteral("CV-370 switched to daylight mode."); + } else if (!transportOk) { + message = reply->errorString(); + } else { + message = QStringLiteral("CV-370 returned HTTP %1.").arg(status); + } + reply->deleteLater(); + emit requestFinished(ok, nightMode, message); + }); +} diff --git a/src/MarshallCv370Controller.h b/src/MarshallCv370Controller.h new file mode 100644 index 0000000..6404c7c --- /dev/null +++ b/src/MarshallCv370Controller.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +class MarshallCv370Controller : public QObject { + Q_OBJECT +public: + explicit MarshallCv370Controller(QObject* parent = nullptr); + + static QUrl buildSetIrCutUrl(const QString& host, bool nightMode); + + void setNightMode(const QString& host, bool nightMode); + +signals: + void requestFinished(bool ok, bool nightMode, const QString& message); + +private: + QNetworkAccessManager* network_ = nullptr; +}; diff --git a/src/Project.cpp b/src/Project.cpp index 1fb7a2e..6114592 100644 --- a/src/Project.cpp +++ b/src/Project.cpp @@ -63,6 +63,9 @@ Project Project::load(const QString& path) { Project p; p.videoSourceType = root["videoSourceType"].toString(); p.ndiSource = root["ndiSource"].toString(); + p.cv370Enabled = root["cv370Enabled"].toBool(false); + p.cv370Host = root["cv370Host"].toString(); + p.cv370NightMode = root["cv370NightMode"].toBool(false); p.decklinkDevice = root["decklinkDevice"].toString(); p.decklinkConnection = root["decklinkConnection"].toString(); p.decklinkAllow10Bit = root["decklinkAllow10Bit"].toBool(true); @@ -126,6 +129,9 @@ void Project::save(const QString& path) const { QJsonObject root; root["videoSourceType"] = videoSourceType; root["ndiSource"] = ndiSource; + root["cv370Enabled"] = cv370Enabled; + root["cv370Host"] = cv370Host; + root["cv370NightMode"] = cv370NightMode; root["decklinkDevice"] = decklinkDevice; root["decklinkConnection"] = decklinkConnection; root["decklinkAllow10Bit"] = decklinkAllow10Bit; diff --git a/src/Project.h b/src/Project.h index a1c7eb2..f09cef6 100644 --- a/src/Project.h +++ b/src/Project.h @@ -46,6 +46,9 @@ struct NetworkConfig { struct Project { QString videoSourceType; // "ndi" | "webcam" | "decklink" (empty = ndi) QString ndiSource; + bool cv370Enabled = false; + QString cv370Host; + bool cv370NightMode = false; QString decklinkDevice; // persistent-ID hash (see DeckLinkCapture::DeviceInfo) QString decklinkConnection; bool decklinkAllow10Bit = true; diff --git a/src/ui/StreamSourcePanel.cpp b/src/ui/StreamSourcePanel.cpp index 288127d..984fed7 100644 --- a/src/ui/StreamSourcePanel.cpp +++ b/src/ui/StreamSourcePanel.cpp @@ -1,10 +1,12 @@ #include "StreamSourcePanel.h" #include "../NdiReceiver.h" +#include "../MarshallCv370Controller.h" #include #include #include #include #include +#include #include #include #include @@ -55,13 +57,60 @@ class NdiSourceTab : public VideoSourceTab { btnRow->addStretch(); layout->addLayout(btnRow); + cv370Check_ = new QCheckBox("Marshall CV-370 camera"); + cv370Check_->setToolTip("Show the day/night toggle for a Marshall CV-370 backing this NDI stream."); + layout->addWidget(cv370Check_); + + auto* cv370HostRow = new QHBoxLayout; + cv370HostLabel_ = new QLabel("CV-370 host:"); + cv370HostEdit_ = new QLineEdit; + cv370HostEdit_->setPlaceholderText("IP or hostname"); + cv370HostRow->addWidget(cv370HostLabel_); + cv370HostRow->addWidget(cv370HostEdit_); + layout->addLayout(cv370HostRow); + + cv370ToggleBtn_ = new QPushButton("Switch to Night Mode"); + layout->addWidget(cv370ToggleBtn_); + + cv370StatusLabel_ = new QLabel; + cv370StatusLabel_->setWordWrap(true); + cv370StatusLabel_->setStyleSheet("color: palette(placeholderText); font-size: 11px;"); + layout->addWidget(cv370StatusLabel_); + layout->addStretch(); + cv370Controller_ = new MarshallCv370Controller(this); + updateCv370Controls(); + connect(refreshBtn_, &QPushButton::clicked, this, &NdiSourceTab::refreshSources); connect(combo_, &QComboBox::currentTextChanged, this, [this](const QString& text) { if (!settingSource_ && !text.isEmpty()) emit sourceActivated(text); }); + connect(cv370Check_, &QCheckBox::toggled, this, [this](bool) { + if (!settingCv370_) emit cv370ConfigChanged(cv370Check_->isChecked(), cv370HostEdit_->text(), cv370NightMode_); + updateCv370Controls(); + }); + connect(cv370HostEdit_, &QLineEdit::textChanged, this, [this](const QString&) { + if (!settingCv370_) emit cv370ConfigChanged(cv370Check_->isChecked(), cv370HostEdit_->text(), cv370NightMode_); + updateCv370Controls(); + }); + connect(cv370ToggleBtn_, &QPushButton::clicked, this, [this]() { + const bool nextNightMode = !cv370NightMode_; + cv370ToggleBtn_->setEnabled(false); + cv370StatusLabel_->setText(nextNightMode ? QStringLiteral("Switching CV-370 to night mode…") + : QStringLiteral("Switching CV-370 to daylight mode…")); + cv370Controller_->setNightMode(cv370HostEdit_->text(), nextNightMode); + }); + connect(cv370Controller_, &MarshallCv370Controller::requestFinished, + this, [this](bool ok, bool nightMode, const QString& message) { + if (ok) { + cv370NightMode_ = nightMode; + if (!settingCv370_) emit cv370ConfigChanged(cv370Check_->isChecked(), cv370HostEdit_->text(), cv370NightMode_); + } + cv370StatusLabel_->setText(message); + updateCv370Controls(); + }); if (ndi_) { connect(ndi_, &NdiReceiver::sourcesChanged, this, [this](QStringList sources) { @@ -113,10 +162,44 @@ class NdiSourceTab : public VideoSourceTab { settingSource_ = false; } + void setMarshallCv370Config(bool enabled, const QString& host, bool nightMode) { + settingCv370_ = true; + cv370Check_->setChecked(enabled); + cv370HostEdit_->setText(host); + cv370NightMode_ = nightMode; + settingCv370_ = false; + cv370StatusLabel_->setText(nightMode ? QStringLiteral("Current mode: night") + : QStringLiteral("Current mode: daylight")); + updateCv370Controls(); + } + +signals: + void cv370ConfigChanged(bool enabled, const QString& host, bool nightMode); + private: + void updateCv370Controls() { + const bool enabled = cv370Check_->isChecked(); + const bool hasHost = !cv370HostEdit_->text().trimmed().isEmpty(); + cv370HostLabel_->setVisible(enabled); + cv370HostEdit_->setVisible(enabled); + cv370ToggleBtn_->setVisible(enabled); + cv370StatusLabel_->setVisible(enabled); + cv370ToggleBtn_->setEnabled(enabled && hasHost); + cv370ToggleBtn_->setText(cv370NightMode_ ? QStringLiteral("Switch to Daylight Mode") + : QStringLiteral("Switch to Night Mode")); + } + NdiReceiver* ndi_; QComboBox* combo_; QPushButton* refreshBtn_; + QCheckBox* cv370Check_ = nullptr; + QLabel* cv370HostLabel_ = nullptr; + QLineEdit* cv370HostEdit_ = nullptr; + QPushButton* cv370ToggleBtn_ = nullptr; + QLabel* cv370StatusLabel_ = nullptr; + MarshallCv370Controller* cv370Controller_ = nullptr; + bool cv370NightMode_ = false; + bool settingCv370_ = false; bool settingSource_ = false; }; @@ -511,7 +594,6 @@ class DecklinkSourceTab : public VideoSourceTab { bool settingSource_ = false; #else void refreshSources() override {} - QString selectedSource() const override { return {}; } void setCurrentSource(const QString&) override {} #endif }; @@ -538,6 +620,8 @@ StreamSourcePanel::StreamSourcePanel(NdiReceiver* ndi, QWidget* parent) connect(ndiTab_, &VideoSourceTab::sourceActivated, this, &StreamSourcePanel::ndiSourceSelected); + connect(ndiTab_, &NdiSourceTab::cv370ConfigChanged, + this, &StreamSourcePanel::marshallCv370ConfigChanged); connect(webcamTab_, &VideoSourceTab::sourceActivated, this, &StreamSourcePanel::webcamSourceSelected); @@ -577,6 +661,10 @@ void StreamSourcePanel::setCurrentNdiSource(const QString& source) { ndiTab_->setCurrentSource(source); } +void StreamSourcePanel::setMarshallCv370Config(bool enabled, const QString& host, bool nightMode) { + ndiTab_->setMarshallCv370Config(enabled, host, nightMode); +} + void StreamSourcePanel::setCurrentDecklinkSource(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit) { diff --git a/src/ui/StreamSourcePanel.h b/src/ui/StreamSourcePanel.h index 4b16724..a87c02b 100644 --- a/src/ui/StreamSourcePanel.h +++ b/src/ui/StreamSourcePanel.h @@ -6,6 +6,7 @@ class NdiReceiver; class QTabWidget; class VideoSourceTab; +class NdiSourceTab; class DecklinkSourceTab; class StreamSourcePanel : public QWidget { @@ -15,6 +16,7 @@ class StreamSourcePanel : public QWidget { QString selectedNdiSource() const; void setCurrentNdiSource(const QString& source); + void setMarshallCv370Config(bool enabled, const QString& host, bool nightMode); void setCurrentDecklinkSource(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit); @@ -23,10 +25,11 @@ class StreamSourcePanel : public QWidget { void webcamSourceSelected(const QString& device); void decklinkSourceSelected(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit); + void marshallCv370ConfigChanged(bool enabled, const QString& host, bool nightMode); private: QTabWidget* tabs_; - VideoSourceTab* ndiTab_; + NdiSourceTab* ndiTab_; VideoSourceTab* webcamTab_; DecklinkSourceTab* decklinkTab_; }; diff --git a/tests/test_marshall_cv370_controller.cpp b/tests/test_marshall_cv370_controller.cpp new file mode 100644 index 0000000..bef8071 --- /dev/null +++ b/tests/test_marshall_cv370_controller.cpp @@ -0,0 +1,35 @@ +#include +#include "MarshallCv370Controller.h" + +class MarshallCv370ControllerTest : public QObject { + Q_OBJECT + +private slots: + void setNightModeBuildsDaylightRequest(); + void setNightModeBuildsNightRequest(); + void hostNormalizationAcceptsSchemeAndTrailingSlash(); +}; + +void MarshallCv370ControllerTest::setNightModeBuildsDaylightRequest() { + const QUrl url = MarshallCv370Controller::buildSetIrCutUrl("192.168.10.42", false); + + QCOMPARE(url.scheme(), QStringLiteral("http")); + QCOMPARE(url.host(), QStringLiteral("192.168.10.42")); + QCOMPARE(url.path(), QStringLiteral("/cgi-bin/web.fcgi")); + QCOMPARE(url.query(QUrl::FullyEncoded), QStringLiteral("func=set%7B%22image%22:%7B%22ircut%22:1%7D%7D")); +} + +void MarshallCv370ControllerTest::setNightModeBuildsNightRequest() { + const QUrl url = MarshallCv370Controller::buildSetIrCutUrl("cv370.local", true); + + QCOMPARE(url.toString(), QStringLiteral("http://cv370.local/cgi-bin/web.fcgi?func=set%7B%22image%22:%7B%22ircut%22:0%7D%7D")); +} + +void MarshallCv370ControllerTest::hostNormalizationAcceptsSchemeAndTrailingSlash() { + const QUrl url = MarshallCv370Controller::buildSetIrCutUrl("http://cv370.local/", false); + + QCOMPARE(url.toString(), QStringLiteral("http://cv370.local/cgi-bin/web.fcgi?func=set%7B%22image%22:%7B%22ircut%22:1%7D%7D")); +} + +QTEST_MAIN(MarshallCv370ControllerTest) +#include "test_marshall_cv370_controller.moc" From 6700b571a2e99e5ee9c2aded2e90c9084a7c3f76 Mon Sep 17 00:00:00 2001 From: rueger-events-bot <287312438+rueger-events-bot@users.noreply.github.com> Date: Sat, 23 May 2026 22:48:35 +0000 Subject: [PATCH 2/6] refactor: modularize CV-370 camera controls --- CMakeLists.txt | 2 +- src/MainWindow.cpp | 10 +- src/MarshallCv370Controller.cpp | 224 ++++++++++++++++++++++- src/MarshallCv370Controller.h | 51 ++++++ src/NdiReceiver.cpp | 13 +- src/NdiReceiver.h | 1 + src/ui/StreamSourcePanel.cpp | 107 ++++------- src/ui/StreamSourcePanel.h | 5 +- tests/test_marshall_cv370_controller.cpp | 15 ++ 9 files changed, 336 insertions(+), 92 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ba6958..26c00e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -185,7 +185,7 @@ if(BUILD_TESTING) src/MarshallCv370Controller.cpp ) target_include_directories(onpoint_tests PRIVATE src) - target_link_libraries(onpoint_tests PRIVATE Qt6::Core Qt6::Network Qt6::Test) + target_link_libraries(onpoint_tests PRIVATE Qt6::Core Qt6::Network Qt6::Widgets Qt6::Test) add_test(NAME marshall_cv370_controller COMMAND onpoint_tests) else() message(WARNING "Qt6 Test component not found; skipping unit tests") diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index f443123..bb1cd78 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -197,10 +197,8 @@ MainWindow::MainWindow(NdiReceiver* ndi, QWidget* parent) : QMainWindow(parent) setDecklinkSource(id, conn, mode, b10); }); connect(streamPanel_, &StreamSourcePanel::marshallCv370ConfigChanged, - this, [this](bool enabled, const QString& host, bool nightMode) { - project_.cv370Enabled = enabled; - project_.cv370Host = host; - project_.cv370NightMode = nightMode; + this, [this](const MarshallCv370Config& config) { + config.writeToProject(project_); markDirty(); }); @@ -658,9 +656,7 @@ void MainWindow::applyProject() { psnReceiver_->stop(); psnReceiver_->wait(); } - streamPanel_->setMarshallCv370Config(project_.cv370Enabled, - project_.cv370Host, - project_.cv370NightMode); + streamPanel_->setMarshallCv370Config(MarshallCv370Config::fromProject(project_)); if (project_.videoSourceType == "decklink" && !project_.decklinkDevice.isEmpty()) { setDecklinkSource(project_.decklinkDevice, project_.decklinkConnection, project_.decklinkDisplayMode, project_.decklinkAllow10Bit); diff --git a/src/MarshallCv370Controller.cpp b/src/MarshallCv370Controller.cpp index 991550b..a3220b4 100644 --- a/src/MarshallCv370Controller.cpp +++ b/src/MarshallCv370Controller.cpp @@ -1,18 +1,36 @@ #include "MarshallCv370Controller.h" +#include "Project.h" +#include +#include #include #include +#include +#include +#include #include #include #include +#include +#include + +MarshallCv370Config MarshallCv370Config::fromProject(const Project& project) { + return {project.cv370Enabled, project.cv370Host, project.cv370NightMode}; +} + +void MarshallCv370Config::writeToProject(Project& project) const { + project.cv370Enabled = enabled; + project.cv370Host = host; + project.cv370NightMode = nightMode; +} MarshallCv370Controller::MarshallCv370Controller(QObject* parent) : QObject(parent), network_(new QNetworkAccessManager(this)) { } -QUrl MarshallCv370Controller::buildSetIrCutUrl(const QString& host, bool nightMode) { - QString normalized = host.trimmed(); +QString MarshallCv370Controller::hostFromNdiUrlAddress(const QString& ndiUrlAddress) { + QString normalized = ndiUrlAddress.trimmed(); if (normalized.isEmpty()) return {}; @@ -21,11 +39,41 @@ QUrl MarshallCv370Controller::buildSetIrCutUrl(const QString& host, bool nightMo normalized.prepend(QStringLiteral("http://")); } - QUrl base(normalized); - if (!base.isValid() || base.host().isEmpty()) + const QUrl url(normalized); + if (!url.isValid() || url.host().isEmpty()) return {}; + return url.host(); +} +static QUrl buildMarshallBaseUrl(const QString& hostOrUrl) { + QString host = MarshallCv370Controller::hostFromNdiUrlAddress(hostOrUrl); + if (host.isEmpty()) + return {}; + + QUrl base; + base.setScheme(QStringLiteral("http")); + base.setHost(host); base.setPath(QStringLiteral("/cgi-bin/web.fcgi")); + return base; +} + +QUrl MarshallCv370Controller::buildDetectUrl(const QString& hostOrNdiUrlAddress) { + QUrl url = buildMarshallBaseUrl(hostOrNdiUrlAddress); + if (!url.isValid() || url.host().isEmpty()) + return {}; + + QJsonObject payload; + payload[QStringLiteral("image")] = QJsonArray{QStringLiteral("ircut")}; + const QString compactJson = QString::fromUtf8( + QJsonDocument(payload).toJson(QJsonDocument::Compact)); + url.setQuery(QStringLiteral("func=get%1").arg(compactJson)); + return url; +} + +QUrl MarshallCv370Controller::buildSetIrCutUrl(const QString& host, bool nightMode) { + QUrl base = buildMarshallBaseUrl(host); + if (!base.isValid() || base.host().isEmpty()) + return {}; QJsonObject image; image[QStringLiteral("ircut")] = nightMode ? 0 : 1; @@ -39,6 +87,46 @@ QUrl MarshallCv370Controller::buildSetIrCutUrl(const QString& host, bool nightMo return base; } +bool MarshallCv370Controller::responseLooksLikeCv370(const QByteArray& body) { + const QString text = QString::fromUtf8(body).toLower(); + return text.contains(QStringLiteral("ircut")) || + (text.contains(QStringLiteral("image")) && text.contains(QStringLiteral("result"))); +} + +void MarshallCv370Controller::detectHost(const QString& hostOrNdiUrlAddress) { + const QUrl url = buildDetectUrl(hostOrNdiUrlAddress); + if (!url.isValid() || url.host().isEmpty()) { + emit detectionFinished(false, {}, QStringLiteral("No camera IP found for the selected NDI source.")); + return; + } + + QNetworkRequest request(url); + auto* reply = network_->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply, host = url.host()]() { + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool transportOk = reply->error() == QNetworkReply::NoError; + const bool httpOk = status == 0 || (status >= 200 && status < 300); + const QByteArray body = reply->readAll(); + const bool detected = transportOk && httpOk && responseLooksLikeCv370(body); + + QString message; + if (detected) { + message = QStringLiteral("Detected Marshall CV-370 at %1 from the NDI stream IP.").arg(host); + } else if (!transportOk) { + message = QStringLiteral("Selected NDI stream IP is %1; CV-370 probe failed: %2") + .arg(host, reply->errorString()); + } else if (!httpOk) { + message = QStringLiteral("Selected NDI stream IP is %1; CV-370 probe returned HTTP %2.") + .arg(host).arg(status); + } else { + message = QStringLiteral("Selected NDI stream IP is %1, but it did not answer like a CV-370.").arg(host); + } + + reply->deleteLater(); + emit detectionFinished(detected, host, message); + }); +} + void MarshallCv370Controller::setNightMode(const QString& host, bool nightMode) { const QUrl url = buildSetIrCutUrl(host, nightMode); if (!url.isValid() || url.host().isEmpty()) { @@ -66,3 +154,131 @@ void MarshallCv370Controller::setNightMode(const QString& host, bool nightMode) emit requestFinished(ok, nightMode, message); }); } + +MarshallCv370Panel::MarshallCv370Panel(QWidget* parent) : QWidget(parent) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 6, 0, 0); + layout->setSpacing(6); + + cameraCheck_ = new QCheckBox(QStringLiteral("Marshall CV-370 camera")); + cameraCheck_->setToolTip(QStringLiteral( + "Automatically probes the selected NDI stream IP for Marshall CV-370 controls.\n" + "Enable manually if the camera is reachable at a different host.")); + layout->addWidget(cameraCheck_); + + auto* hostRow = new QHBoxLayout; + hostLabel_ = new QLabel(QStringLiteral("CV-370 host:")); + hostEdit_ = new QLineEdit; + hostEdit_->setPlaceholderText(QStringLiteral("IP or hostname")); + hostRow->addWidget(hostLabel_); + hostRow->addWidget(hostEdit_); + layout->addLayout(hostRow); + + toggleBtn_ = new QPushButton(QStringLiteral("Switch to Night Mode")); + layout->addWidget(toggleBtn_); + + statusLabel_ = new QLabel; + statusLabel_->setWordWrap(true); + statusLabel_->setStyleSheet(QStringLiteral("color: palette(placeholderText); font-size: 11px;")); + layout->addWidget(statusLabel_); + + controller_ = new MarshallCv370Controller(this); + updateControls(); + + connect(cameraCheck_, &QCheckBox::toggled, this, [this](bool) { + if (!setting_) emitConfigChanged(); + updateControls(); + }); + connect(hostEdit_, &QLineEdit::textEdited, this, [this](const QString&) { + userEditedHost_ = true; + }); + connect(hostEdit_, &QLineEdit::textChanged, this, [this](const QString&) { + if (!setting_) emitConfigChanged(); + updateControls(); + }); + connect(toggleBtn_, &QPushButton::clicked, this, [this]() { + const bool nextNightMode = !nightMode_; + toggleBtn_->setEnabled(false); + statusLabel_->setText(nextNightMode ? QStringLiteral("Switching CV-370 to night mode…") + : QStringLiteral("Switching CV-370 to daylight mode…")); + controller_->setNightMode(hostEdit_->text(), nextNightMode); + }); + connect(controller_, &MarshallCv370Controller::detectionFinished, + this, [this](bool detected, const QString& host, const QString& message) { + statusLabel_->setText(message); + if (detected) { + setting_ = true; + cameraCheck_->setChecked(true); + if (!userEditedHost_ || hostEdit_->text().trimmed().isEmpty() || hostEdit_->text() == lastDetectedHost_) + hostEdit_->setText(host); + lastDetectedHost_ = host; + setting_ = false; + emitConfigChanged(); + } + updateControls(); + }); + connect(controller_, &MarshallCv370Controller::requestFinished, + this, [this](bool ok, bool nightMode, const QString& message) { + if (ok) { + nightMode_ = nightMode; + emitConfigChanged(); + } + statusLabel_->setText(message); + updateControls(); + }); +} + +MarshallCv370Config MarshallCv370Panel::config() const { + return {cameraCheck_->isChecked(), hostEdit_->text(), nightMode_}; +} + +void MarshallCv370Panel::setConfig(const MarshallCv370Config& config) { + setting_ = true; + cameraCheck_->setChecked(config.enabled); + hostEdit_->setText(config.host); + nightMode_ = config.nightMode; + userEditedHost_ = !config.host.trimmed().isEmpty(); + setting_ = false; + statusLabel_->setText(nightMode_ ? QStringLiteral("Current mode: night") + : QStringLiteral("Current mode: daylight")); + updateControls(); +} + +void MarshallCv370Panel::setNdiSourceEndpoint(const QString& sourceName, const QString& ndiUrlAddress) { + if (sourceName == currentNdiSource_ && ndiUrlAddress.isEmpty()) + return; + currentNdiSource_ = sourceName; + + const QString host = MarshallCv370Controller::hostFromNdiUrlAddress(ndiUrlAddress); + if (host.isEmpty()) { + if (!sourceName.isEmpty()) + statusLabel_->setText(QStringLiteral("Selected NDI stream does not expose an IP address.")); + return; + } + + if (!userEditedHost_ || hostEdit_->text().trimmed().isEmpty()) { + setting_ = true; + hostEdit_->setText(host); + setting_ = false; + } + + statusLabel_->setText(QStringLiteral("Checking selected NDI stream IP %1 for CV-370 controls…").arg(host)); + controller_->detectHost(host); + updateControls(); +} + +void MarshallCv370Panel::emitConfigChanged() { + emit configChanged(config()); +} + +void MarshallCv370Panel::updateControls() { + const bool enabled = cameraCheck_->isChecked(); + const bool hasHost = !hostEdit_->text().trimmed().isEmpty(); + hostLabel_->setVisible(enabled); + hostEdit_->setVisible(enabled); + toggleBtn_->setVisible(enabled); + statusLabel_->setVisible(enabled || !statusLabel_->text().isEmpty()); + toggleBtn_->setEnabled(enabled && hasHost); + toggleBtn_->setText(nightMode_ ? QStringLiteral("Switch to Daylight Mode") + : QStringLiteral("Switch to Night Mode")); +} diff --git a/src/MarshallCv370Controller.h b/src/MarshallCv370Controller.h index 6404c7c..88da85a 100644 --- a/src/MarshallCv370Controller.h +++ b/src/MarshallCv370Controller.h @@ -1,23 +1,74 @@ #pragma once #include +#include #include +#include +class Project; +class QLabel; +class QCheckBox; +class QLineEdit; class QNetworkAccessManager; class QNetworkReply; +class QPushButton; + +struct MarshallCv370Config { + bool enabled = false; + QString host; + bool nightMode = false; + + static MarshallCv370Config fromProject(const Project& project); + void writeToProject(Project& project) const; +}; class MarshallCv370Controller : public QObject { Q_OBJECT public: explicit MarshallCv370Controller(QObject* parent = nullptr); + static QString hostFromNdiUrlAddress(const QString& ndiUrlAddress); + static QUrl buildDetectUrl(const QString& hostOrNdiUrlAddress); static QUrl buildSetIrCutUrl(const QString& host, bool nightMode); + static bool responseLooksLikeCv370(const QByteArray& body); + void detectHost(const QString& hostOrNdiUrlAddress); void setNightMode(const QString& host, bool nightMode); signals: + void detectionFinished(bool detected, const QString& host, const QString& message); void requestFinished(bool ok, bool nightMode, const QString& message); private: QNetworkAccessManager* network_ = nullptr; }; + +class MarshallCv370Panel : public QWidget { + Q_OBJECT +public: + explicit MarshallCv370Panel(QWidget* parent = nullptr); + + MarshallCv370Config config() const; + void setConfig(const MarshallCv370Config& config); + void setNdiSourceEndpoint(const QString& sourceName, const QString& ndiUrlAddress); + +signals: + void configChanged(const MarshallCv370Config& config); + +private: + void emitConfigChanged(); + void updateControls(); + + QCheckBox* cameraCheck_ = nullptr; + QLabel* hostLabel_ = nullptr; + QLineEdit* hostEdit_ = nullptr; + QPushButton* toggleBtn_ = nullptr; + QLabel* statusLabel_ = nullptr; + + MarshallCv370Controller* controller_ = nullptr; + QString currentNdiSource_; + QString lastDetectedHost_; + bool nightMode_ = false; + bool setting_ = false; + bool userEditedHost_ = false; +}; diff --git a/src/NdiReceiver.cpp b/src/NdiReceiver.cpp index a95c8e7..099aae2 100644 --- a/src/NdiReceiver.cpp +++ b/src/NdiReceiver.cpp @@ -1,5 +1,7 @@ #include "NdiReceiver.h" #include +#include +#include #include #if NDI_AVAILABLE @@ -29,10 +31,17 @@ void NdiReceiver::discoverSources() { uint32_t num = 0; const NDIlib_source_t* srcs = NDIlib_find_get_current_sources(finder, &num); QStringList names; - for (uint32_t i = 0; i < num; ++i) - names << QString::fromUtf8(srcs[i].p_ndi_name); + QList> endpoints; + for (uint32_t i = 0; i < num; ++i) { + const QString name = QString::fromUtf8(srcs[i].p_ndi_name); + names << name; + if (srcs[i].p_url_address) + endpoints.append({name, QString::fromUtf8(srcs[i].p_url_address)}); + } NDIlib_find_destroy(finder); emit sourcesChanged(names); + for (const auto& endpoint : endpoints) + emit sourceEndpointChanged(endpoint.first, endpoint.second); }); #else emit sourcesChanged({"[No NDI SDK — demo mode]"}); diff --git a/src/NdiReceiver.h b/src/NdiReceiver.h index 99f5596..bdb6f2c 100644 --- a/src/NdiReceiver.h +++ b/src/NdiReceiver.h @@ -20,6 +20,7 @@ class NdiReceiver : public QThread { signals: void frameReady(QImage frame); void sourcesChanged(QStringList sources); + void sourceEndpointChanged(const QString& sourceName, const QString& urlAddress); protected: void run() override; diff --git a/src/ui/StreamSourcePanel.cpp b/src/ui/StreamSourcePanel.cpp index 984fed7..8c82ffa 100644 --- a/src/ui/StreamSourcePanel.cpp +++ b/src/ui/StreamSourcePanel.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "../DeckLinkCapture.h" #if WEBCAM_AVAILABLE # include @@ -57,60 +58,21 @@ class NdiSourceTab : public VideoSourceTab { btnRow->addStretch(); layout->addLayout(btnRow); - cv370Check_ = new QCheckBox("Marshall CV-370 camera"); - cv370Check_->setToolTip("Show the day/night toggle for a Marshall CV-370 backing this NDI stream."); - layout->addWidget(cv370Check_); - - auto* cv370HostRow = new QHBoxLayout; - cv370HostLabel_ = new QLabel("CV-370 host:"); - cv370HostEdit_ = new QLineEdit; - cv370HostEdit_->setPlaceholderText("IP or hostname"); - cv370HostRow->addWidget(cv370HostLabel_); - cv370HostRow->addWidget(cv370HostEdit_); - layout->addLayout(cv370HostRow); - - cv370ToggleBtn_ = new QPushButton("Switch to Night Mode"); - layout->addWidget(cv370ToggleBtn_); - - cv370StatusLabel_ = new QLabel; - cv370StatusLabel_->setWordWrap(true); - cv370StatusLabel_->setStyleSheet("color: palette(placeholderText); font-size: 11px;"); - layout->addWidget(cv370StatusLabel_); + marshallPanel_ = new MarshallCv370Panel; + layout->addWidget(marshallPanel_); layout->addStretch(); - cv370Controller_ = new MarshallCv370Controller(this); - updateCv370Controls(); + updateMarshallSourceEndpoint(); connect(refreshBtn_, &QPushButton::clicked, this, &NdiSourceTab::refreshSources); connect(combo_, &QComboBox::currentTextChanged, this, [this](const QString& text) { + updateMarshallSourceEndpoint(); if (!settingSource_ && !text.isEmpty()) emit sourceActivated(text); }); - connect(cv370Check_, &QCheckBox::toggled, this, [this](bool) { - if (!settingCv370_) emit cv370ConfigChanged(cv370Check_->isChecked(), cv370HostEdit_->text(), cv370NightMode_); - updateCv370Controls(); - }); - connect(cv370HostEdit_, &QLineEdit::textChanged, this, [this](const QString&) { - if (!settingCv370_) emit cv370ConfigChanged(cv370Check_->isChecked(), cv370HostEdit_->text(), cv370NightMode_); - updateCv370Controls(); - }); - connect(cv370ToggleBtn_, &QPushButton::clicked, this, [this]() { - const bool nextNightMode = !cv370NightMode_; - cv370ToggleBtn_->setEnabled(false); - cv370StatusLabel_->setText(nextNightMode ? QStringLiteral("Switching CV-370 to night mode…") - : QStringLiteral("Switching CV-370 to daylight mode…")); - cv370Controller_->setNightMode(cv370HostEdit_->text(), nextNightMode); - }); - connect(cv370Controller_, &MarshallCv370Controller::requestFinished, - this, [this](bool ok, bool nightMode, const QString& message) { - if (ok) { - cv370NightMode_ = nightMode; - if (!settingCv370_) emit cv370ConfigChanged(cv370Check_->isChecked(), cv370HostEdit_->text(), cv370NightMode_); - } - cv370StatusLabel_->setText(message); - updateCv370Controls(); - }); + connect(marshallPanel_, &MarshallCv370Panel::configChanged, + this, &NdiSourceTab::marshallCv370ConfigChanged); if (ndi_) { connect(ndi_, &NdiReceiver::sourcesChanged, this, [this](QStringList sources) { @@ -136,6 +98,13 @@ class NdiSourceTab : public VideoSourceTab { if (shouldActivate && !combo_->currentText().isEmpty()) emit sourceActivated(combo_->currentText()); + updateMarshallSourceEndpoint(); + }); + connect(ndi_, &NdiReceiver::sourceEndpointChanged, + this, [this](const QString& sourceName, const QString& urlAddress) { + sourceEndpoints_[sourceName] = urlAddress; + if (sourceName == combo_->currentText()) + updateMarshallSourceEndpoint(); }); } @@ -162,44 +131,30 @@ class NdiSourceTab : public VideoSourceTab { settingSource_ = false; } - void setMarshallCv370Config(bool enabled, const QString& host, bool nightMode) { - settingCv370_ = true; - cv370Check_->setChecked(enabled); - cv370HostEdit_->setText(host); - cv370NightMode_ = nightMode; - settingCv370_ = false; - cv370StatusLabel_->setText(nightMode ? QStringLiteral("Current mode: night") - : QStringLiteral("Current mode: daylight")); - updateCv370Controls(); + void setMarshallCv370Config(const MarshallCv370Config& config) { + marshallPanel_->setConfig(config); + } + + MarshallCv370Config marshallCv370Config() const { + return marshallPanel_->config(); } signals: - void cv370ConfigChanged(bool enabled, const QString& host, bool nightMode); + void marshallCv370ConfigChanged(const MarshallCv370Config& config); private: - void updateCv370Controls() { - const bool enabled = cv370Check_->isChecked(); - const bool hasHost = !cv370HostEdit_->text().trimmed().isEmpty(); - cv370HostLabel_->setVisible(enabled); - cv370HostEdit_->setVisible(enabled); - cv370ToggleBtn_->setVisible(enabled); - cv370StatusLabel_->setVisible(enabled); - cv370ToggleBtn_->setEnabled(enabled && hasHost); - cv370ToggleBtn_->setText(cv370NightMode_ ? QStringLiteral("Switch to Daylight Mode") - : QStringLiteral("Switch to Night Mode")); + void updateMarshallSourceEndpoint() { + if (!marshallPanel_) + return; + const QString source = combo_->currentText(); + marshallPanel_->setNdiSourceEndpoint(source, sourceEndpoints_.value(source)); } NdiReceiver* ndi_; QComboBox* combo_; QPushButton* refreshBtn_; - QCheckBox* cv370Check_ = nullptr; - QLabel* cv370HostLabel_ = nullptr; - QLineEdit* cv370HostEdit_ = nullptr; - QPushButton* cv370ToggleBtn_ = nullptr; - QLabel* cv370StatusLabel_ = nullptr; - MarshallCv370Controller* cv370Controller_ = nullptr; - bool cv370NightMode_ = false; - bool settingCv370_ = false; + MarshallCv370Panel* marshallPanel_ = nullptr; + QMap sourceEndpoints_; bool settingSource_ = false; }; @@ -620,7 +575,7 @@ StreamSourcePanel::StreamSourcePanel(NdiReceiver* ndi, QWidget* parent) connect(ndiTab_, &VideoSourceTab::sourceActivated, this, &StreamSourcePanel::ndiSourceSelected); - connect(ndiTab_, &NdiSourceTab::cv370ConfigChanged, + connect(ndiTab_, &NdiSourceTab::marshallCv370ConfigChanged, this, &StreamSourcePanel::marshallCv370ConfigChanged); connect(webcamTab_, &VideoSourceTab::sourceActivated, @@ -661,8 +616,8 @@ void StreamSourcePanel::setCurrentNdiSource(const QString& source) { ndiTab_->setCurrentSource(source); } -void StreamSourcePanel::setMarshallCv370Config(bool enabled, const QString& host, bool nightMode) { - ndiTab_->setMarshallCv370Config(enabled, host, nightMode); +void StreamSourcePanel::setMarshallCv370Config(const MarshallCv370Config& config) { + ndiTab_->setMarshallCv370Config(config); } void StreamSourcePanel::setCurrentDecklinkSource(const QString& deviceId, diff --git a/src/ui/StreamSourcePanel.h b/src/ui/StreamSourcePanel.h index a87c02b..1b02a3a 100644 --- a/src/ui/StreamSourcePanel.h +++ b/src/ui/StreamSourcePanel.h @@ -2,6 +2,7 @@ #include #include #include +#include "../MarshallCv370Controller.h" class NdiReceiver; class QTabWidget; @@ -16,7 +17,7 @@ class StreamSourcePanel : public QWidget { QString selectedNdiSource() const; void setCurrentNdiSource(const QString& source); - void setMarshallCv370Config(bool enabled, const QString& host, bool nightMode); + void setMarshallCv370Config(const MarshallCv370Config& config); void setCurrentDecklinkSource(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit); @@ -25,7 +26,7 @@ class StreamSourcePanel : public QWidget { void webcamSourceSelected(const QString& device); void decklinkSourceSelected(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit); - void marshallCv370ConfigChanged(bool enabled, const QString& host, bool nightMode); + void marshallCv370ConfigChanged(const MarshallCv370Config& config); private: QTabWidget* tabs_; diff --git a/tests/test_marshall_cv370_controller.cpp b/tests/test_marshall_cv370_controller.cpp index bef8071..df9992b 100644 --- a/tests/test_marshall_cv370_controller.cpp +++ b/tests/test_marshall_cv370_controller.cpp @@ -8,6 +8,8 @@ private slots: void setNightModeBuildsDaylightRequest(); void setNightModeBuildsNightRequest(); void hostNormalizationAcceptsSchemeAndTrailingSlash(); + void extractsHostFromNdiUrlAddress(); + void buildsDetectionRequest(); }; void MarshallCv370ControllerTest::setNightModeBuildsDaylightRequest() { @@ -31,5 +33,18 @@ void MarshallCv370ControllerTest::hostNormalizationAcceptsSchemeAndTrailingSlash QCOMPARE(url.toString(), QStringLiteral("http://cv370.local/cgi-bin/web.fcgi?func=set%7B%22image%22:%7B%22ircut%22:1%7D%7D")); } +void MarshallCv370ControllerTest::extractsHostFromNdiUrlAddress() { + QCOMPARE(MarshallCv370Controller::hostFromNdiUrlAddress("192.168.10.42:5961"), + QStringLiteral("192.168.10.42")); + QCOMPARE(MarshallCv370Controller::hostFromNdiUrlAddress("http://cv370.local:5961/path"), + QStringLiteral("cv370.local")); +} + +void MarshallCv370ControllerTest::buildsDetectionRequest() { + const QUrl url = MarshallCv370Controller::buildDetectUrl("192.168.10.42:5961"); + + QCOMPARE(url.toString(), QStringLiteral("http://192.168.10.42/cgi-bin/web.fcgi?func=get%7B%22image%22:[%22ircut%22]%7D")); +} + QTEST_MAIN(MarshallCv370ControllerTest) #include "test_marshall_cv370_controller.moc" From bb1bba8c2ffb5fe33bf4d560c1f6da3ba0dcb6bf Mon Sep 17 00:00:00 2001 From: root Date: Sun, 24 May 2026 09:02:27 +0000 Subject: [PATCH 3/6] refactor: move camera controls behind registry --- CMakeLists.txt | 4 + src/CameraControl.cpp | 157 +++++++++++++++++++++++ src/CameraControl.h | 72 +++++++++++ src/MainWindow.cpp | 8 +- src/MarshallCv370Controller.cpp | 54 ++++---- src/MarshallCv370Controller.h | 27 ++-- src/Project.cpp | 31 ++++- src/Project.h | 11 +- src/ui/StreamSourcePanel.cpp | 43 +++---- src/ui/StreamSourcePanel.h | 6 +- tests/test_marshall_cv370_controller.cpp | 66 ++++++++++ 11 files changed, 393 insertions(+), 86 deletions(-) create mode 100644 src/CameraControl.cpp create mode 100644 src/CameraControl.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 26c00e6..fcca64f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,7 @@ set(SOURCES src/MainWindow.cpp src/VideoWidget.cpp src/NdiReceiver.cpp + src/CameraControl.cpp src/MarshallCv370Controller.cpp src/WebcamCapture.cpp src/DeckLinkCapture.cpp @@ -69,6 +70,7 @@ set(HEADERS src/MainWindow.h src/VideoWidget.h src/NdiReceiver.h + src/CameraControl.h src/MarshallCv370Controller.h src/WebcamCapture.h src/DeckLinkCapture.h @@ -182,7 +184,9 @@ if(BUILD_TESTING) if(TARGET Qt6::Test) add_executable(onpoint_tests tests/test_marshall_cv370_controller.cpp + src/CameraControl.cpp src/MarshallCv370Controller.cpp + src/Project.cpp ) target_include_directories(onpoint_tests PRIVATE src) target_link_libraries(onpoint_tests PRIVATE Qt6::Core Qt6::Network Qt6::Widgets Qt6::Test) diff --git a/src/CameraControl.cpp b/src/CameraControl.cpp new file mode 100644 index 0000000..07b0a9f --- /dev/null +++ b/src/CameraControl.cpp @@ -0,0 +1,157 @@ +#include "CameraControl.h" + +#include "MarshallCv370Controller.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +void registerBuiltInCamera(CameraControlPanel* panel) { + registerMarshallCv370Camera(panel); +} +} + +CameraControlPanel::CameraControlPanel(QWidget* parent) : QWidget(parent) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 6, 0, 0); + layout->setSpacing(6); + + enabledCheck_ = new QCheckBox(QStringLiteral("Camera control")); + enabledCheck_->setToolTip(QStringLiteral( + "Enable control for supported cameras. Add another camera by registering a camera panel type.")); + layout->addWidget(enabledCheck_); + + auto* typeRow = new QHBoxLayout; + auto* typeLabel = new QLabel(QStringLiteral("Type:")); + typeCombo_ = new QComboBox; + typeRow->addWidget(typeLabel); + typeRow->addWidget(typeCombo_); + layout->addLayout(typeRow); + + stack_ = new QStackedWidget; + emptyLabel_ = new QLabel(QStringLiteral("No camera control types registered.")); + emptyLabel_->setWordWrap(true); + emptyLabel_->setStyleSheet(QStringLiteral("color: palette(placeholderText); font-size: 11px;")); + stack_->addWidget(emptyLabel_); + layout->addWidget(stack_); + + registerBuiltInCameras(); + updateControls(); + + connect(enabledCheck_, &QCheckBox::toggled, this, [this]() { + updateControls(); + if (!setting_) emitConfigChanged(); + }); + connect(typeCombo_, &QComboBox::currentIndexChanged, this, [this](int idx) { + const QString type = typeCombo_->itemData(idx).toString(); + activateType(type); + if (!setting_) emitConfigChanged(); + }); +} + +void CameraControlPanel::registerBuiltInCameras() { + registerBuiltInCamera(this); +} + +void CameraControlPanel::registerCamera(const QString& type, const QString& displayName, CameraFactory factory) { + if (type.trimmed().isEmpty() || cameras_.contains(type)) + return; + + RegisteredCamera camera; + camera.displayName = displayName; + camera.factory = std::move(factory); + cameras_.insert(type, camera); + typeCombo_->addItem(displayName, type); + + if (currentType_.isEmpty()) { + setting_ = true; + typeCombo_->setCurrentIndex(typeCombo_->count() - 1); + activateType(type); + setting_ = false; + } + updateControls(); +} + +CameraControlConfig CameraControlPanel::config() const { + CameraControlConfig cfg; + cfg.type = currentType_; + cfg.enabled = enabledCheck_->isChecked(); + cfg.config = perTypeConfig_; + if (!currentType_.isEmpty()) + cfg.config[currentType_] = configForType(currentType_); + return cfg; +} + +void CameraControlPanel::setConfig(const CameraControlConfig& config) { + setting_ = true; + perTypeConfig_ = config.config; + enabledCheck_->setChecked(config.enabled); + + QString type = config.type; + if (type.isEmpty() && typeCombo_->count() > 0) + type = typeCombo_->itemData(0).toString(); + + const int idx = typeCombo_->findData(type); + if (idx >= 0) + typeCombo_->setCurrentIndex(idx); + activateType(idx >= 0 ? type : QString{}); + setting_ = false; + updateControls(); +} + +void CameraControlPanel::setNdiSourceEndpoint(const QString& sourceName, const QString& ndiUrlAddress) { + currentNdiSource_ = sourceName; + currentNdiEndpoint_ = ndiUrlAddress; + if (auto it = cameras_.find(currentType_); it != cameras_.end() && it->panel) + it->panel->setNdiSourceEndpoint(sourceName, ndiUrlAddress); +} + +void CameraControlPanel::emitConfigChanged() { + emit configChanged(config()); +} + +void CameraControlPanel::updateControls() { + const bool hasTypes = typeCombo_->count() > 0; + enabledCheck_->setEnabled(hasTypes); + typeCombo_->setVisible(hasTypes); + stack_->setVisible(hasTypes || emptyLabel_); + stack_->setEnabled(enabledCheck_->isChecked()); +} + +void CameraControlPanel::activateType(const QString& type) { + if (!currentType_.isEmpty()) + perTypeConfig_[currentType_] = configForType(currentType_); + + currentType_ = type; + auto it = cameras_.find(type); + if (it == cameras_.end()) { + stack_->setCurrentWidget(emptyLabel_); + return; + } + + if (!it->panel) { + it->panel = it->factory(stack_); + stack_->addWidget(it->panel); + connect(it->panel, &CameraSettingsPanel::configChanged, this, [this]() { + if (!currentType_.isEmpty()) + perTypeConfig_[currentType_] = configForType(currentType_); + if (!setting_) emitConfigChanged(); + }); + } + + it->panel->setConfigJson(perTypeConfig_.value(type).toObject()); + it->panel->setNdiSourceEndpoint(currentNdiSource_, currentNdiEndpoint_); + stack_->setCurrentWidget(it->panel); +} + +QJsonObject CameraControlPanel::configForType(const QString& type) const { + auto it = cameras_.constFind(type); + if (it == cameras_.constEnd() || !it->panel) + return perTypeConfig_.value(type).toObject(); + return it->panel->configJson(); +} diff --git a/src/CameraControl.h b/src/CameraControl.h new file mode 100644 index 0000000..503f198 --- /dev/null +++ b/src/CameraControl.h @@ -0,0 +1,72 @@ +#pragma once + +#include "Project.h" + +#include +#include +#include +#include + +class QCheckBox; +class QComboBox; +class QLabel; +class QStackedWidget; + +class CameraSettingsPanel : public QWidget { + Q_OBJECT +public: + explicit CameraSettingsPanel(QWidget* parent = nullptr) : QWidget(parent) {} + ~CameraSettingsPanel() override = default; + + virtual QJsonObject configJson() const = 0; + virtual void setConfigJson(const QJsonObject& config) = 0; + virtual void setNdiSourceEndpoint(const QString& sourceName, const QString& ndiUrlAddress) { + Q_UNUSED(sourceName) + Q_UNUSED(ndiUrlAddress) + } + +signals: + void configChanged(); +}; + +class CameraControlPanel : public QWidget { + Q_OBJECT +public: + using CameraFactory = std::function; + + explicit CameraControlPanel(QWidget* parent = nullptr); + + void registerCamera(const QString& type, const QString& displayName, CameraFactory factory); + + CameraControlConfig config() const; + void setConfig(const CameraControlConfig& config); + void setNdiSourceEndpoint(const QString& sourceName, const QString& ndiUrlAddress); + +signals: + void configChanged(const CameraControlConfig& config); + +private: + void registerBuiltInCameras(); + void emitConfigChanged(); + void updateControls(); + void activateType(const QString& type); + QJsonObject configForType(const QString& type) const; + + struct RegisteredCamera { + QString displayName; + CameraFactory factory; + CameraSettingsPanel* panel = nullptr; + }; + + QCheckBox* enabledCheck_ = nullptr; + QComboBox* typeCombo_ = nullptr; + QStackedWidget* stack_ = nullptr; + QLabel* emptyLabel_ = nullptr; + + QMap cameras_; + QString currentType_; + QString currentNdiSource_; + QString currentNdiEndpoint_; + QJsonObject perTypeConfig_; + bool setting_ = false; +}; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index bb1cd78..d2af1be 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -196,9 +196,9 @@ MainWindow::MainWindow(NdiReceiver* ndi, QWidget* parent) : QMainWindow(parent) this, [this](const QString& id, const QString& conn, uint32_t mode, bool b10) { setDecklinkSource(id, conn, mode, b10); }); - connect(streamPanel_, &StreamSourcePanel::marshallCv370ConfigChanged, - this, [this](const MarshallCv370Config& config) { - config.writeToProject(project_); + connect(streamPanel_, &StreamSourcePanel::cameraControlConfigChanged, + this, [this](const CameraControlConfig& config) { + project_.cameraControl = config; markDirty(); }); @@ -656,7 +656,7 @@ void MainWindow::applyProject() { psnReceiver_->stop(); psnReceiver_->wait(); } - streamPanel_->setMarshallCv370Config(MarshallCv370Config::fromProject(project_)); + streamPanel_->setCameraControlConfig(project_.cameraControl); if (project_.videoSourceType == "decklink" && !project_.decklinkDevice.isEmpty()) { setDecklinkSource(project_.decklinkDevice, project_.decklinkConnection, project_.decklinkDisplayMode, project_.decklinkAllow10Bit); diff --git a/src/MarshallCv370Controller.cpp b/src/MarshallCv370Controller.cpp index a3220b4..202a301 100644 --- a/src/MarshallCv370Controller.cpp +++ b/src/MarshallCv370Controller.cpp @@ -1,6 +1,4 @@ #include "MarshallCv370Controller.h" -#include "Project.h" - #include #include #include @@ -14,16 +12,6 @@ #include #include -MarshallCv370Config MarshallCv370Config::fromProject(const Project& project) { - return {project.cv370Enabled, project.cv370Host, project.cv370NightMode}; -} - -void MarshallCv370Config::writeToProject(Project& project) const { - project.cv370Enabled = enabled; - project.cv370Host = host; - project.cv370NightMode = nightMode; -} - MarshallCv370Controller::MarshallCv370Controller(QObject* parent) : QObject(parent), network_(new QNetworkAccessManager(this)) { @@ -155,15 +143,17 @@ void MarshallCv370Controller::setNightMode(const QString& host, bool nightMode) }); } -MarshallCv370Panel::MarshallCv370Panel(QWidget* parent) : QWidget(parent) { +MarshallCv370Panel::MarshallCv370Panel(QWidget* parent) : CameraSettingsPanel(parent) { auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 6, 0, 0); layout->setSpacing(6); cameraCheck_ = new QCheckBox(QStringLiteral("Marshall CV-370 camera")); + cameraCheck_->setChecked(true); + cameraCheck_->setVisible(false); cameraCheck_->setToolTip(QStringLiteral( "Automatically probes the selected NDI stream IP for Marshall CV-370 controls.\n" - "Enable manually if the camera is reachable at a different host.")); + "Use the generic Camera control checkbox above to enable or disable camera control.")); layout->addWidget(cameraCheck_); auto* hostRow = new QHBoxLayout; @@ -228,16 +218,18 @@ MarshallCv370Panel::MarshallCv370Panel(QWidget* parent) : QWidget(parent) { }); } -MarshallCv370Config MarshallCv370Panel::config() const { - return {cameraCheck_->isChecked(), hostEdit_->text(), nightMode_}; +QJsonObject MarshallCv370Panel::configJson() const { + QJsonObject config; + config[QStringLiteral("host")] = hostEdit_->text(); + config[QStringLiteral("nightMode")] = nightMode_; + return config; } -void MarshallCv370Panel::setConfig(const MarshallCv370Config& config) { +void MarshallCv370Panel::setConfigJson(const QJsonObject& config) { setting_ = true; - cameraCheck_->setChecked(config.enabled); - hostEdit_->setText(config.host); - nightMode_ = config.nightMode; - userEditedHost_ = !config.host.trimmed().isEmpty(); + hostEdit_->setText(config[QStringLiteral("host")].toString()); + nightMode_ = config[QStringLiteral("nightMode")].toBool(false); + userEditedHost_ = !hostEdit_->text().trimmed().isEmpty(); setting_ = false; statusLabel_->setText(nightMode_ ? QStringLiteral("Current mode: night") : QStringLiteral("Current mode: daylight")); @@ -268,17 +260,23 @@ void MarshallCv370Panel::setNdiSourceEndpoint(const QString& sourceName, const Q } void MarshallCv370Panel::emitConfigChanged() { - emit configChanged(config()); + emit configChanged(); } void MarshallCv370Panel::updateControls() { - const bool enabled = cameraCheck_->isChecked(); const bool hasHost = !hostEdit_->text().trimmed().isEmpty(); - hostLabel_->setVisible(enabled); - hostEdit_->setVisible(enabled); - toggleBtn_->setVisible(enabled); - statusLabel_->setVisible(enabled || !statusLabel_->text().isEmpty()); - toggleBtn_->setEnabled(enabled && hasHost); + hostLabel_->setVisible(true); + hostEdit_->setVisible(true); + toggleBtn_->setVisible(true); + statusLabel_->setVisible(!statusLabel_->text().isEmpty()); + toggleBtn_->setEnabled(hasHost); toggleBtn_->setText(nightMode_ ? QStringLiteral("Switch to Daylight Mode") : QStringLiteral("Switch to Night Mode")); } + +void registerMarshallCv370Camera(CameraControlPanel* cameraControl) { + cameraControl->registerCamera( + QStringLiteral("cv370"), + QStringLiteral("Marshall CV-370"), + [](QWidget* parent) { return new MarshallCv370Panel(parent); }); +} diff --git a/src/MarshallCv370Controller.h b/src/MarshallCv370Controller.h index 88da85a..35bcd5a 100644 --- a/src/MarshallCv370Controller.h +++ b/src/MarshallCv370Controller.h @@ -1,27 +1,17 @@ #pragma once +#include "CameraControl.h" + #include #include #include -#include -class Project; class QLabel; class QCheckBox; class QLineEdit; class QNetworkAccessManager; -class QNetworkReply; class QPushButton; -struct MarshallCv370Config { - bool enabled = false; - QString host; - bool nightMode = false; - - static MarshallCv370Config fromProject(const Project& project); - void writeToProject(Project& project) const; -}; - class MarshallCv370Controller : public QObject { Q_OBJECT public: @@ -43,17 +33,14 @@ class MarshallCv370Controller : public QObject { QNetworkAccessManager* network_ = nullptr; }; -class MarshallCv370Panel : public QWidget { +class MarshallCv370Panel : public CameraSettingsPanel { Q_OBJECT public: explicit MarshallCv370Panel(QWidget* parent = nullptr); - MarshallCv370Config config() const; - void setConfig(const MarshallCv370Config& config); - void setNdiSourceEndpoint(const QString& sourceName, const QString& ndiUrlAddress); - -signals: - void configChanged(const MarshallCv370Config& config); + QJsonObject configJson() const override; + void setConfigJson(const QJsonObject& config) override; + void setNdiSourceEndpoint(const QString& sourceName, const QString& ndiUrlAddress) override; private: void emitConfigChanged(); @@ -72,3 +59,5 @@ class MarshallCv370Panel : public QWidget { bool setting_ = false; bool userEditedHost_ = false; }; + +void registerMarshallCv370Camera(CameraControlPanel* cameraControl); diff --git a/src/Project.cpp b/src/Project.cpp index 6114592..9fb6fde 100644 --- a/src/Project.cpp +++ b/src/Project.cpp @@ -63,9 +63,26 @@ Project Project::load(const QString& path) { Project p; p.videoSourceType = root["videoSourceType"].toString(); p.ndiSource = root["ndiSource"].toString(); - p.cv370Enabled = root["cv370Enabled"].toBool(false); - p.cv370Host = root["cv370Host"].toString(); - p.cv370NightMode = root["cv370NightMode"].toBool(false); + const bool hasCameraControl = root.contains(QStringLiteral("cameraControl")); + if (hasCameraControl) { + QJsonObject cameraControl = root["cameraControl"].toObject(); + p.cameraControl.type = cameraControl["type"].toString(QStringLiteral("cv370")); + p.cameraControl.enabled = cameraControl["enabled"].toBool(false); + p.cameraControl.config = cameraControl["config"].toObject(); + } + + // Backward compatibility for PR-era showfiles with flat CV-370 keys. + if (!hasCameraControl && + (root.contains(QStringLiteral("cv370Enabled")) || + root.contains(QStringLiteral("cv370Host")) || + root.contains(QStringLiteral("cv370NightMode")))) { + p.cameraControl.type = QStringLiteral("cv370"); + p.cameraControl.enabled = root["cv370Enabled"].toBool(false); + QJsonObject cv370; + cv370[QStringLiteral("host")] = root["cv370Host"].toString(); + cv370[QStringLiteral("nightMode")] = root["cv370NightMode"].toBool(false); + p.cameraControl.config[QStringLiteral("cv370")] = cv370; + } p.decklinkDevice = root["decklinkDevice"].toString(); p.decklinkConnection = root["decklinkConnection"].toString(); p.decklinkAllow10Bit = root["decklinkAllow10Bit"].toBool(true); @@ -129,9 +146,11 @@ void Project::save(const QString& path) const { QJsonObject root; root["videoSourceType"] = videoSourceType; root["ndiSource"] = ndiSource; - root["cv370Enabled"] = cv370Enabled; - root["cv370Host"] = cv370Host; - root["cv370NightMode"] = cv370NightMode; + QJsonObject cameraControlObject; + cameraControlObject["type"] = cameraControl.type; + cameraControlObject["enabled"] = cameraControl.enabled; + cameraControlObject["config"] = cameraControl.config; + root["cameraControl"] = cameraControlObject; root["decklinkDevice"] = decklinkDevice; root["decklinkConnection"] = decklinkConnection; root["decklinkAllow10Bit"] = decklinkAllow10Bit; diff --git a/src/Project.h b/src/Project.h index f09cef6..0e31c96 100644 --- a/src/Project.h +++ b/src/Project.h @@ -4,6 +4,7 @@ #include #include #include +#include struct TrackerConfig { int id = 1; @@ -43,12 +44,16 @@ struct NetworkConfig { QString sessionInterface; // NIC name for DNS-SD + TCP session (empty = OS default) }; +struct CameraControlConfig { + QString type = QStringLiteral("cv370"); + bool enabled = false; + QJsonObject config; +}; + struct Project { QString videoSourceType; // "ndi" | "webcam" | "decklink" (empty = ndi) QString ndiSource; - bool cv370Enabled = false; - QString cv370Host; - bool cv370NightMode = false; + CameraControlConfig cameraControl; QString decklinkDevice; // persistent-ID hash (see DeckLinkCapture::DeviceInfo) QString decklinkConnection; bool decklinkAllow10Bit = true; diff --git a/src/ui/StreamSourcePanel.cpp b/src/ui/StreamSourcePanel.cpp index 8c82ffa..7ce5a6a 100644 --- a/src/ui/StreamSourcePanel.cpp +++ b/src/ui/StreamSourcePanel.cpp @@ -1,6 +1,6 @@ #include "StreamSourcePanel.h" #include "../NdiReceiver.h" -#include "../MarshallCv370Controller.h" +#include "../CameraControl.h" #include #include #include @@ -58,21 +58,21 @@ class NdiSourceTab : public VideoSourceTab { btnRow->addStretch(); layout->addLayout(btnRow); - marshallPanel_ = new MarshallCv370Panel; - layout->addWidget(marshallPanel_); + cameraControl_ = new CameraControlPanel; + layout->addWidget(cameraControl_); layout->addStretch(); - updateMarshallSourceEndpoint(); + updateCameraControlSourceEndpoint(); connect(refreshBtn_, &QPushButton::clicked, this, &NdiSourceTab::refreshSources); connect(combo_, &QComboBox::currentTextChanged, this, [this](const QString& text) { - updateMarshallSourceEndpoint(); + updateCameraControlSourceEndpoint(); if (!settingSource_ && !text.isEmpty()) emit sourceActivated(text); }); - connect(marshallPanel_, &MarshallCv370Panel::configChanged, - this, &NdiSourceTab::marshallCv370ConfigChanged); + connect(cameraControl_, &CameraControlPanel::configChanged, + this, &NdiSourceTab::cameraControlConfigChanged); if (ndi_) { connect(ndi_, &NdiReceiver::sourcesChanged, this, [this](QStringList sources) { @@ -98,13 +98,13 @@ class NdiSourceTab : public VideoSourceTab { if (shouldActivate && !combo_->currentText().isEmpty()) emit sourceActivated(combo_->currentText()); - updateMarshallSourceEndpoint(); + updateCameraControlSourceEndpoint(); }); connect(ndi_, &NdiReceiver::sourceEndpointChanged, this, [this](const QString& sourceName, const QString& urlAddress) { sourceEndpoints_[sourceName] = urlAddress; if (sourceName == combo_->currentText()) - updateMarshallSourceEndpoint(); + updateCameraControlSourceEndpoint(); }); } @@ -131,29 +131,26 @@ class NdiSourceTab : public VideoSourceTab { settingSource_ = false; } - void setMarshallCv370Config(const MarshallCv370Config& config) { - marshallPanel_->setConfig(config); + void setCameraControlConfig(const CameraControlConfig& config) { + cameraControl_->setConfig(config); } - MarshallCv370Config marshallCv370Config() const { - return marshallPanel_->config(); - } signals: - void marshallCv370ConfigChanged(const MarshallCv370Config& config); + void cameraControlConfigChanged(const CameraControlConfig& config); private: - void updateMarshallSourceEndpoint() { - if (!marshallPanel_) + void updateCameraControlSourceEndpoint() { + if (!cameraControl_) return; const QString source = combo_->currentText(); - marshallPanel_->setNdiSourceEndpoint(source, sourceEndpoints_.value(source)); + cameraControl_->setNdiSourceEndpoint(source, sourceEndpoints_.value(source)); } NdiReceiver* ndi_; QComboBox* combo_; QPushButton* refreshBtn_; - MarshallCv370Panel* marshallPanel_ = nullptr; + CameraControlPanel* cameraControl_ = nullptr; QMap sourceEndpoints_; bool settingSource_ = false; }; @@ -575,8 +572,8 @@ StreamSourcePanel::StreamSourcePanel(NdiReceiver* ndi, QWidget* parent) connect(ndiTab_, &VideoSourceTab::sourceActivated, this, &StreamSourcePanel::ndiSourceSelected); - connect(ndiTab_, &NdiSourceTab::marshallCv370ConfigChanged, - this, &StreamSourcePanel::marshallCv370ConfigChanged); + connect(ndiTab_, &NdiSourceTab::cameraControlConfigChanged, + this, &StreamSourcePanel::cameraControlConfigChanged); connect(webcamTab_, &VideoSourceTab::sourceActivated, this, &StreamSourcePanel::webcamSourceSelected); @@ -616,8 +613,8 @@ void StreamSourcePanel::setCurrentNdiSource(const QString& source) { ndiTab_->setCurrentSource(source); } -void StreamSourcePanel::setMarshallCv370Config(const MarshallCv370Config& config) { - ndiTab_->setMarshallCv370Config(config); +void StreamSourcePanel::setCameraControlConfig(const CameraControlConfig& config) { + ndiTab_->setCameraControlConfig(config); } void StreamSourcePanel::setCurrentDecklinkSource(const QString& deviceId, diff --git a/src/ui/StreamSourcePanel.h b/src/ui/StreamSourcePanel.h index 1b02a3a..85e96b7 100644 --- a/src/ui/StreamSourcePanel.h +++ b/src/ui/StreamSourcePanel.h @@ -2,7 +2,7 @@ #include #include #include -#include "../MarshallCv370Controller.h" +#include "../CameraControl.h" class NdiReceiver; class QTabWidget; @@ -17,7 +17,7 @@ class StreamSourcePanel : public QWidget { QString selectedNdiSource() const; void setCurrentNdiSource(const QString& source); - void setMarshallCv370Config(const MarshallCv370Config& config); + void setCameraControlConfig(const CameraControlConfig& config); void setCurrentDecklinkSource(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit); @@ -26,7 +26,7 @@ class StreamSourcePanel : public QWidget { void webcamSourceSelected(const QString& device); void decklinkSourceSelected(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit); - void marshallCv370ConfigChanged(const MarshallCv370Config& config); + void cameraControlConfigChanged(const CameraControlConfig& config); private: QTabWidget* tabs_; diff --git a/tests/test_marshall_cv370_controller.cpp b/tests/test_marshall_cv370_controller.cpp index df9992b..0c58e09 100644 --- a/tests/test_marshall_cv370_controller.cpp +++ b/tests/test_marshall_cv370_controller.cpp @@ -1,5 +1,10 @@ #include +#include +#include +#include +#include #include "MarshallCv370Controller.h" +#include "Project.h" class MarshallCv370ControllerTest : public QObject { Q_OBJECT @@ -10,6 +15,8 @@ private slots: void hostNormalizationAcceptsSchemeAndTrailingSlash(); void extractsHostFromNdiUrlAddress(); void buildsDetectionRequest(); + void projectSaveUsesCameraControlObject(); + void projectLoadMigratesFlatCv370Keys(); }; void MarshallCv370ControllerTest::setNightModeBuildsDaylightRequest() { @@ -46,5 +53,64 @@ void MarshallCv370ControllerTest::buildsDetectionRequest() { QCOMPARE(url.toString(), QStringLiteral("http://192.168.10.42/cgi-bin/web.fcgi?func=get%7B%22image%22:[%22ircut%22]%7D")); } +void MarshallCv370ControllerTest::projectSaveUsesCameraControlObject() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("show.onpoint")); + + Project project = Project::defaultProject(); + project.cameraControl.type = QStringLiteral("cv370"); + project.cameraControl.enabled = true; + QJsonObject cv370; + cv370[QStringLiteral("host")] = QStringLiteral("192.168.10.42"); + cv370[QStringLiteral("nightMode")] = true; + project.cameraControl.config[QStringLiteral("cv370")] = cv370; + + project.save(path); + + QFile file(path); + QVERIFY(file.open(QIODevice::ReadOnly)); + const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); + QVERIFY(root.contains(QStringLiteral("cameraControl"))); + QVERIFY(!root.contains(QStringLiteral("cv370Enabled"))); + + const QJsonObject cameraControl = root[QStringLiteral("cameraControl")].toObject(); + QCOMPARE(cameraControl[QStringLiteral("type")].toString(), QStringLiteral("cv370")); + QCOMPARE(cameraControl[QStringLiteral("enabled")].toBool(), true); + QCOMPARE(cameraControl[QStringLiteral("config")].toObject() + [QStringLiteral("cv370")].toObject() + [QStringLiteral("host")].toString(), + QStringLiteral("192.168.10.42")); + QCOMPARE(cameraControl[QStringLiteral("config")].toObject() + [QStringLiteral("cv370")].toObject() + [QStringLiteral("nightMode")].toBool(), + true); +} + +void MarshallCv370ControllerTest::projectLoadMigratesFlatCv370Keys() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("legacy.onpoint")); + + QJsonObject root; + root[QStringLiteral("cv370Enabled")] = true; + root[QStringLiteral("cv370Host")] = QStringLiteral("cv370.local"); + root[QStringLiteral("cv370NightMode")] = true; + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write(QJsonDocument(root).toJson()); + file.close(); + + const Project project = Project::load(path); + QCOMPARE(project.cameraControl.type, QStringLiteral("cv370")); + QCOMPARE(project.cameraControl.enabled, true); + QCOMPARE(project.cameraControl.config[QStringLiteral("cv370")].toObject() + [QStringLiteral("host")].toString(), + QStringLiteral("cv370.local")); + QCOMPARE(project.cameraControl.config[QStringLiteral("cv370")].toObject() + [QStringLiteral("nightMode")].toBool(), + true); +} + QTEST_MAIN(MarshallCv370ControllerTest) #include "test_marshall_cv370_controller.moc" From 064887fd3fbd9fb5a47470146c625e184b13358c Mon Sep 17 00:00:00 2001 From: root Date: Sun, 24 May 2026 09:10:11 +0000 Subject: [PATCH 4/6] refactor: drop flat cv370 showfile migration --- src/Project.cpp | 16 +-------------- tests/test_marshall_cv370_controller.cpp | 26 ------------------------ 2 files changed, 1 insertion(+), 41 deletions(-) diff --git a/src/Project.cpp b/src/Project.cpp index 9fb6fde..89501cd 100644 --- a/src/Project.cpp +++ b/src/Project.cpp @@ -63,26 +63,12 @@ Project Project::load(const QString& path) { Project p; p.videoSourceType = root["videoSourceType"].toString(); p.ndiSource = root["ndiSource"].toString(); - const bool hasCameraControl = root.contains(QStringLiteral("cameraControl")); - if (hasCameraControl) { + if (root.contains(QStringLiteral("cameraControl"))) { QJsonObject cameraControl = root["cameraControl"].toObject(); p.cameraControl.type = cameraControl["type"].toString(QStringLiteral("cv370")); p.cameraControl.enabled = cameraControl["enabled"].toBool(false); p.cameraControl.config = cameraControl["config"].toObject(); } - - // Backward compatibility for PR-era showfiles with flat CV-370 keys. - if (!hasCameraControl && - (root.contains(QStringLiteral("cv370Enabled")) || - root.contains(QStringLiteral("cv370Host")) || - root.contains(QStringLiteral("cv370NightMode")))) { - p.cameraControl.type = QStringLiteral("cv370"); - p.cameraControl.enabled = root["cv370Enabled"].toBool(false); - QJsonObject cv370; - cv370[QStringLiteral("host")] = root["cv370Host"].toString(); - cv370[QStringLiteral("nightMode")] = root["cv370NightMode"].toBool(false); - p.cameraControl.config[QStringLiteral("cv370")] = cv370; - } p.decklinkDevice = root["decklinkDevice"].toString(); p.decklinkConnection = root["decklinkConnection"].toString(); p.decklinkAllow10Bit = root["decklinkAllow10Bit"].toBool(true); diff --git a/tests/test_marshall_cv370_controller.cpp b/tests/test_marshall_cv370_controller.cpp index 0c58e09..5ec5a6f 100644 --- a/tests/test_marshall_cv370_controller.cpp +++ b/tests/test_marshall_cv370_controller.cpp @@ -16,7 +16,6 @@ private slots: void extractsHostFromNdiUrlAddress(); void buildsDetectionRequest(); void projectSaveUsesCameraControlObject(); - void projectLoadMigratesFlatCv370Keys(); }; void MarshallCv370ControllerTest::setNightModeBuildsDaylightRequest() { @@ -87,30 +86,5 @@ void MarshallCv370ControllerTest::projectSaveUsesCameraControlObject() { true); } -void MarshallCv370ControllerTest::projectLoadMigratesFlatCv370Keys() { - QTemporaryDir dir; - QVERIFY(dir.isValid()); - const QString path = dir.filePath(QStringLiteral("legacy.onpoint")); - - QJsonObject root; - root[QStringLiteral("cv370Enabled")] = true; - root[QStringLiteral("cv370Host")] = QStringLiteral("cv370.local"); - root[QStringLiteral("cv370NightMode")] = true; - QFile file(path); - QVERIFY(file.open(QIODevice::WriteOnly)); - file.write(QJsonDocument(root).toJson()); - file.close(); - - const Project project = Project::load(path); - QCOMPARE(project.cameraControl.type, QStringLiteral("cv370")); - QCOMPARE(project.cameraControl.enabled, true); - QCOMPARE(project.cameraControl.config[QStringLiteral("cv370")].toObject() - [QStringLiteral("host")].toString(), - QStringLiteral("cv370.local")); - QCOMPARE(project.cameraControl.config[QStringLiteral("cv370")].toObject() - [QStringLiteral("nightMode")].toBool(), - true); -} - QTEST_MAIN(MarshallCv370ControllerTest) #include "test_marshall_cv370_controller.moc" From f033f6d5933950f99495ab713563223a91083d17 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 24 May 2026 10:05:21 +0000 Subject: [PATCH 5/6] refactor: move camera control to sidebar panel --- src/CameraControl.cpp | 6 ++++- src/MainWindow.cpp | 9 +++++-- src/MainWindow.h | 2 ++ src/MarshallCv370Controller.cpp | 5 +++- src/ui/StreamSourcePanel.cpp | 43 ++++++++++++--------------------- src/ui/StreamSourcePanel.h | 4 +-- 6 files changed, 34 insertions(+), 35 deletions(-) diff --git a/src/CameraControl.cpp b/src/CameraControl.cpp index 07b0a9f..979538c 100644 --- a/src/CameraControl.cpp +++ b/src/CameraControl.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -29,8 +30,11 @@ CameraControlPanel::CameraControlPanel(QWidget* parent) : QWidget(parent) { auto* typeRow = new QHBoxLayout; auto* typeLabel = new QLabel(QStringLiteral("Type:")); typeCombo_ = new QComboBox; + typeCombo_->setMinimumContentsLength(0); + typeCombo_->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); + typeCombo_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); typeRow->addWidget(typeLabel); - typeRow->addWidget(typeCombo_); + typeRow->addWidget(typeCombo_, 1); layout->addLayout(typeRow); stack_ = new QStackedWidget; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index d2af1be..5c4edfe 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -12,6 +12,7 @@ #include "ui/TrackersPanel.h" #include "ui/TrackerBar.h" #include "ui/StreamSourcePanel.h" +#include "CameraControl.h" #include "ui/NetworkSettingsPanel.h" #include "ui/StatsPanel.h" #include "ui/CalibrationPanel.h" @@ -85,6 +86,7 @@ MainWindow::MainWindow(NdiReceiver* ndi, QWidget* parent) : QMainWindow(parent) sessionPanel_ = new SessionPanel(sessionMgr_); streamPanel_ = new StreamSourcePanel(ndi_); + cameraControlPanel_ = new CameraControlPanel; calibrationPanel_= new CalibrationPanel(video_, ndi_, this); trackersPanel_ = new TrackersPanel; networkPanel_ = new NetworkSettingsPanel; @@ -92,6 +94,7 @@ MainWindow::MainWindow(NdiReceiver* ndi, QWidget* parent) : QMainWindow(parent) sidebar_->addPanel("Session", sessionPanel_, false); sidebar_->addPanel("Stream Source", streamPanel_, true); + sidebar_->addPanel("Camera Control", cameraControlPanel_, false); calibrationSection_ = sidebar_->addPanel("Calibration", calibrationPanel_, false); sidebar_->addPanel("Trackers", trackersPanel_, true); sidebar_->addPanel("Network", networkPanel_, false); @@ -196,7 +199,9 @@ MainWindow::MainWindow(NdiReceiver* ndi, QWidget* parent) : QMainWindow(parent) this, [this](const QString& id, const QString& conn, uint32_t mode, bool b10) { setDecklinkSource(id, conn, mode, b10); }); - connect(streamPanel_, &StreamSourcePanel::cameraControlConfigChanged, + connect(streamPanel_, &StreamSourcePanel::ndiSourceEndpointChanged, + cameraControlPanel_, &CameraControlPanel::setNdiSourceEndpoint); + connect(cameraControlPanel_, &CameraControlPanel::configChanged, this, [this](const CameraControlConfig& config) { project_.cameraControl = config; markDirty(); @@ -656,7 +661,7 @@ void MainWindow::applyProject() { psnReceiver_->stop(); psnReceiver_->wait(); } - streamPanel_->setCameraControlConfig(project_.cameraControl); + cameraControlPanel_->setConfig(project_.cameraControl); if (project_.videoSourceType == "decklink" && !project_.decklinkDevice.isEmpty()) { setDecklinkSource(project_.decklinkDevice, project_.decklinkConnection, project_.decklinkDisplayMode, project_.decklinkAllow10Bit); diff --git a/src/MainWindow.h b/src/MainWindow.h index 275a31f..b5758e0 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -24,6 +24,7 @@ class SidebarWidget; class TrackersPanel; class TrackerBar; class StreamSourcePanel; +class CameraControlPanel; class NetworkSettingsPanel; class StatsPanel; class CalibrationPanel; @@ -81,6 +82,7 @@ private slots: TrackersPanel* trackersPanel_; TrackerBar* trackerBar_; StreamSourcePanel* streamPanel_; + CameraControlPanel* cameraControlPanel_; NetworkSettingsPanel* networkPanel_; StatsPanel* statsPanel_; CalibrationPanel* calibrationPanel_; diff --git a/src/MarshallCv370Controller.cpp b/src/MarshallCv370Controller.cpp index 202a301..6df27d1 100644 --- a/src/MarshallCv370Controller.cpp +++ b/src/MarshallCv370Controller.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include MarshallCv370Controller::MarshallCv370Controller(QObject* parent) @@ -160,11 +161,13 @@ MarshallCv370Panel::MarshallCv370Panel(QWidget* parent) : CameraSettingsPanel(pa hostLabel_ = new QLabel(QStringLiteral("CV-370 host:")); hostEdit_ = new QLineEdit; hostEdit_->setPlaceholderText(QStringLiteral("IP or hostname")); + hostEdit_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); hostRow->addWidget(hostLabel_); - hostRow->addWidget(hostEdit_); + hostRow->addWidget(hostEdit_, 1); layout->addLayout(hostRow); toggleBtn_ = new QPushButton(QStringLiteral("Switch to Night Mode")); + toggleBtn_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); layout->addWidget(toggleBtn_); statusLabel_ = new QLabel; diff --git a/src/ui/StreamSourcePanel.cpp b/src/ui/StreamSourcePanel.cpp index 7ce5a6a..e252eac 100644 --- a/src/ui/StreamSourcePanel.cpp +++ b/src/ui/StreamSourcePanel.cpp @@ -1,6 +1,5 @@ #include "StreamSourcePanel.h" #include "../NdiReceiver.h" -#include "../CameraControl.h" #include #include #include @@ -12,6 +11,7 @@ #include #include #include +#include #include "../DeckLinkCapture.h" #if WEBCAM_AVAILABLE # include @@ -47,8 +47,9 @@ class NdiSourceTab : public VideoSourceTab { layout->setSpacing(6); combo_ = new QComboBox; - combo_->setMinimumContentsLength(16); + combo_->setMinimumContentsLength(0); combo_->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); + combo_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); combo_->setPlaceholderText("No sources found"); layout->addWidget(combo_); @@ -58,21 +59,16 @@ class NdiSourceTab : public VideoSourceTab { btnRow->addStretch(); layout->addLayout(btnRow); - cameraControl_ = new CameraControlPanel; - layout->addWidget(cameraControl_); - layout->addStretch(); - updateCameraControlSourceEndpoint(); + emitCurrentSourceEndpoint(); connect(refreshBtn_, &QPushButton::clicked, this, &NdiSourceTab::refreshSources); connect(combo_, &QComboBox::currentTextChanged, this, [this](const QString& text) { - updateCameraControlSourceEndpoint(); + emitCurrentSourceEndpoint(); if (!settingSource_ && !text.isEmpty()) emit sourceActivated(text); }); - connect(cameraControl_, &CameraControlPanel::configChanged, - this, &NdiSourceTab::cameraControlConfigChanged); if (ndi_) { connect(ndi_, &NdiReceiver::sourcesChanged, this, [this](QStringList sources) { @@ -98,13 +94,13 @@ class NdiSourceTab : public VideoSourceTab { if (shouldActivate && !combo_->currentText().isEmpty()) emit sourceActivated(combo_->currentText()); - updateCameraControlSourceEndpoint(); + emitCurrentSourceEndpoint(); }); connect(ndi_, &NdiReceiver::sourceEndpointChanged, this, [this](const QString& sourceName, const QString& urlAddress) { sourceEndpoints_[sourceName] = urlAddress; if (sourceName == combo_->currentText()) - updateCameraControlSourceEndpoint(); + emitCurrentSourceEndpoint(); }); } @@ -129,28 +125,22 @@ class NdiSourceTab : public VideoSourceTab { combo_->setCurrentIndex(combo_->count() - 1); } settingSource_ = false; - } - - void setCameraControlConfig(const CameraControlConfig& config) { - cameraControl_->setConfig(config); + emitCurrentSourceEndpoint(); } signals: - void cameraControlConfigChanged(const CameraControlConfig& config); + void sourceEndpointChanged(const QString& sourceName, const QString& urlAddress); private: - void updateCameraControlSourceEndpoint() { - if (!cameraControl_) - return; + void emitCurrentSourceEndpoint() { const QString source = combo_->currentText(); - cameraControl_->setNdiSourceEndpoint(source, sourceEndpoints_.value(source)); + emit sourceEndpointChanged(source, sourceEndpoints_.value(source)); } NdiReceiver* ndi_; QComboBox* combo_; QPushButton* refreshBtn_; - CameraControlPanel* cameraControl_ = nullptr; QMap sourceEndpoints_; bool settingSource_ = false; }; @@ -166,8 +156,9 @@ class WebcamSourceTab : public VideoSourceTab { layout->setSpacing(6); combo_ = new QComboBox; - combo_->setMinimumContentsLength(16); + combo_->setMinimumContentsLength(0); combo_->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); + combo_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); combo_->setPlaceholderText("No cameras found"); layout->addWidget(combo_); @@ -572,8 +563,8 @@ StreamSourcePanel::StreamSourcePanel(NdiReceiver* ndi, QWidget* parent) connect(ndiTab_, &VideoSourceTab::sourceActivated, this, &StreamSourcePanel::ndiSourceSelected); - connect(ndiTab_, &NdiSourceTab::cameraControlConfigChanged, - this, &StreamSourcePanel::cameraControlConfigChanged); + connect(ndiTab_, &NdiSourceTab::sourceEndpointChanged, + this, &StreamSourcePanel::ndiSourceEndpointChanged); connect(webcamTab_, &VideoSourceTab::sourceActivated, this, &StreamSourcePanel::webcamSourceSelected); @@ -613,10 +604,6 @@ void StreamSourcePanel::setCurrentNdiSource(const QString& source) { ndiTab_->setCurrentSource(source); } -void StreamSourcePanel::setCameraControlConfig(const CameraControlConfig& config) { - ndiTab_->setCameraControlConfig(config); -} - void StreamSourcePanel::setCurrentDecklinkSource(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit) { diff --git a/src/ui/StreamSourcePanel.h b/src/ui/StreamSourcePanel.h index 85e96b7..f2334eb 100644 --- a/src/ui/StreamSourcePanel.h +++ b/src/ui/StreamSourcePanel.h @@ -2,7 +2,6 @@ #include #include #include -#include "../CameraControl.h" class NdiReceiver; class QTabWidget; @@ -17,7 +16,6 @@ class StreamSourcePanel : public QWidget { QString selectedNdiSource() const; void setCurrentNdiSource(const QString& source); - void setCameraControlConfig(const CameraControlConfig& config); void setCurrentDecklinkSource(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit); @@ -26,7 +24,7 @@ class StreamSourcePanel : public QWidget { void webcamSourceSelected(const QString& device); void decklinkSourceSelected(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit); - void cameraControlConfigChanged(const CameraControlConfig& config); + void ndiSourceEndpointChanged(const QString& sourceName, const QString& urlAddress); private: QTabWidget* tabs_; From a535b0676cbdebdbc5a7e7d2e20f3b472a8ff5aa Mon Sep 17 00:00:00 2001 From: root Date: Sun, 24 May 2026 10:11:29 +0000 Subject: [PATCH 6/6] ci: skip packaging when NDI SDK secret is unavailable --- .github/workflows/build.yml | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1468b2e..497f13e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,8 @@ jobs: build-macos: name: macOS ARM runs-on: macos-14 + env: + NDI_SDK_URL_MAC: ${{ secrets.NDI_SDK_URL_MAC }} steps: - uses: actions/checkout@v4 @@ -37,10 +39,15 @@ jobs: run: brew install cmake qt opencv - name: Install NDI SDK + if: env.NDI_SDK_URL_MAC != '' run: | - curl -L "${{ secrets.NDI_SDK_URL_MAC }}" -o ndi-sdk.pkg + curl -L "$NDI_SDK_URL_MAC" -o ndi-sdk.pkg sudo installer -pkg ndi-sdk.pkg -target / + - name: Skip macOS build without NDI SDK URL + if: env.NDI_SDK_URL_MAC == '' + run: echo "NDI_SDK_URL_MAC is not configured; skipping macOS packaging for this branch." + - name: Import signing certificate # Requires secrets: APPLE_CERTIFICATE (base64 .p12), APPLE_CERTIFICATE_PASSWORD if: ${{ env.APPLE_CERTIFICATE != '' }} @@ -61,19 +68,23 @@ jobs: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - name: Configure + if: env.NDI_SDK_URL_MAC != '' run: | cmake -B build \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH="$(brew --prefix qt);$(brew --prefix opencv)" - name: Build + if: env.NDI_SDK_URL_MAC != '' run: cmake --build build --parallel $(sysctl -n hw.logicalcpu) - name: Bundle + if: env.NDI_SDK_URL_MAC != '' run: | "$(brew --prefix qt)/bin/macdeployqt" build/onpoint.app - name: Strip Homebrew rpaths from bundle + if: env.NDI_SDK_URL_MAC != '' # macdeployqt bundles Homebrew OpenCV libs but leaves their original # LC_RPATH entries intact. At runtime dyld resolves @rpath/libopencv_* # references against those paths and loads a second OpenCV/libomp stack @@ -105,6 +116,7 @@ jobs: build/onpoint.app - name: Create DMG + if: env.NDI_SDK_URL_MAC != '' run: | mkdir -p dmg_staging cp -R build/onpoint.app dmg_staging/ @@ -136,6 +148,7 @@ jobs: xcrun stapler staple OnPoint.dmg - name: Upload artifact + if: env.NDI_SDK_URL_MAC != '' uses: actions/upload-artifact@v4 with: name: OnPoint-macOS @@ -146,6 +159,8 @@ jobs: build-windows: name: Windows x64 runs-on: windows-2022 + env: + NDI_SDK_URL_WIN: ${{ secrets.NDI_SDK_URL_WIN }} steps: - uses: actions/checkout@v4 @@ -181,11 +196,12 @@ jobs: key: vcpkg-opencv4-x64-windows-v2 - name: Install NDI SDK + if: env.NDI_SDK_URL_WIN != '' # The NDI installer is Inno Setup (no /S flag); use innoextract to unpack # without a GUI, then point NDI_SDK_DIR at the extracted app/ directory. shell: pwsh run: | - $url = "${{ secrets.NDI_SDK_URL_WIN }}" + $url = $env:NDI_SDK_URL_WIN $installer = Join-Path $env:RUNNER_TEMP "ndi-sdk.exe" curl.exe -L --retry 3 --retry-delay 5 $url -o $installer @@ -201,7 +217,12 @@ jobs: } "NDI_SDK_DIR=$appDir" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + - name: Skip Windows build without NDI SDK URL + if: env.NDI_SDK_URL_WIN == '' + run: echo "NDI_SDK_URL_WIN is not configured; skipping Windows packaging for this branch." + - name: Set up Bonjour SDK + if: env.NDI_SDK_URL_WIN != '' # Installs Bonjour runtime, fetches dns_sd.h (Apache 2.0 from mDNSResponder), # and generates dnssd.lib from the installed DLL via dumpbin + lib.exe. shell: pwsh @@ -236,6 +257,7 @@ jobs: /out:"third_party\bonjour\Lib\x64\dnssd.lib" - name: Configure + if: env.NDI_SDK_URL_WIN != '' run: | cmake -S . -B build -A x64 ` -DCMAKE_BUILD_TYPE=Release ` @@ -245,20 +267,24 @@ jobs: -DVCPKG_TARGET_TRIPLET=x64-windows - name: Build + if: env.NDI_SDK_URL_WIN != '' run: cmake --build build --config Release --parallel - name: Deploy Qt runtime + if: env.NDI_SDK_URL_WIN != '' run: | & "$env:QT_ROOT_DIR\bin\windeployqt.exe" --release --compiler-runtime "build\Release\onpoint.exe" Copy-Item "C:\Windows\System32\dnssd.dll" "build\Release\" -Force Copy-Item "C:\vcpkg\installed\x64-windows\bin\opencv_*.dll" "build\Release\" -Force - name: Create installer + if: env.NDI_SDK_URL_WIN != '' run: | $version = if ("${{ github.ref_name }}" -match '^v(.+)$') { $Matches[1] } else { "0.0.0" } makensis /DVERSION="$version" packaging\windows.nsi - name: Upload artifact + if: env.NDI_SDK_URL_WIN != '' uses: actions/upload-artifact@v4 with: name: OnPoint-Windows