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 diff --git a/CMakeLists.txt b/CMakeLists.txt index e55e5b7..fcca64f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,8 @@ set(SOURCES src/MainWindow.cpp src/VideoWidget.cpp src/NdiReceiver.cpp + src/CameraControl.cpp + src/MarshallCv370Controller.cpp src/WebcamCapture.cpp src/DeckLinkCapture.cpp src/PsnSender.cpp @@ -68,6 +70,8 @@ set(HEADERS src/MainWindow.h src/VideoWidget.h src/NdiReceiver.h + src/CameraControl.h + src/MarshallCv370Controller.h src/WebcamCapture.h src/DeckLinkCapture.h src/PsnSender.h @@ -174,6 +178,24 @@ 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/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) + 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/CameraControl.cpp b/src/CameraControl.cpp new file mode 100644 index 0000000..979538c --- /dev/null +++ b/src/CameraControl.cpp @@ -0,0 +1,161 @@ +#include "CameraControl.h" + +#include "MarshallCv370Controller.h" + +#include +#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; + typeCombo_->setMinimumContentsLength(0); + typeCombo_->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); + typeCombo_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + typeRow->addWidget(typeLabel); + typeRow->addWidget(typeCombo_, 1); + 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 2a4370f..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,6 +199,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::ndiSourceEndpointChanged, + cameraControlPanel_, &CameraControlPanel::setNdiSourceEndpoint); + connect(cameraControlPanel_, &CameraControlPanel::configChanged, + this, [this](const CameraControlConfig& config) { + project_.cameraControl = config; + markDirty(); + }); connect(networkPanel_, &NetworkSettingsPanel::configChanged, this, [this](const NetworkConfig& cfg) { @@ -651,6 +661,7 @@ void MainWindow::applyProject() { psnReceiver_->stop(); psnReceiver_->wait(); } + 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 new file mode 100644 index 0000000..6df27d1 --- /dev/null +++ b/src/MarshallCv370Controller.cpp @@ -0,0 +1,285 @@ +#include "MarshallCv370Controller.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MarshallCv370Controller::MarshallCv370Controller(QObject* parent) + : QObject(parent), network_(new QNetworkAccessManager(this)) +{ +} + +QString MarshallCv370Controller::hostFromNdiUrlAddress(const QString& ndiUrlAddress) { + QString normalized = ndiUrlAddress.trimmed(); + if (normalized.isEmpty()) + return {}; + + if (!normalized.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive) && + !normalized.startsWith(QStringLiteral("https://"), Qt::CaseInsensitive)) { + normalized.prepend(QStringLiteral("http://")); + } + + 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; + + 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; +} + +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()) { + 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); + }); +} + +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" + "Use the generic Camera control checkbox above to enable or disable camera control.")); + layout->addWidget(cameraCheck_); + + auto* hostRow = new QHBoxLayout; + 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_, 1); + layout->addLayout(hostRow); + + toggleBtn_ = new QPushButton(QStringLiteral("Switch to Night Mode")); + toggleBtn_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + 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(); + }); +} + +QJsonObject MarshallCv370Panel::configJson() const { + QJsonObject config; + config[QStringLiteral("host")] = hostEdit_->text(); + config[QStringLiteral("nightMode")] = nightMode_; + return config; +} + +void MarshallCv370Panel::setConfigJson(const QJsonObject& config) { + setting_ = true; + 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")); + 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(); +} + +void MarshallCv370Panel::updateControls() { + const bool hasHost = !hostEdit_->text().trimmed().isEmpty(); + 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 new file mode 100644 index 0000000..35bcd5a --- /dev/null +++ b/src/MarshallCv370Controller.h @@ -0,0 +1,63 @@ +#pragma once + +#include "CameraControl.h" + +#include +#include +#include + +class QLabel; +class QCheckBox; +class QLineEdit; +class QNetworkAccessManager; +class QPushButton; + +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 CameraSettingsPanel { + Q_OBJECT +public: + explicit MarshallCv370Panel(QWidget* parent = nullptr); + + QJsonObject configJson() const override; + void setConfigJson(const QJsonObject& config) override; + void setNdiSourceEndpoint(const QString& sourceName, const QString& ndiUrlAddress) override; + +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; +}; + +void registerMarshallCv370Camera(CameraControlPanel* cameraControl); 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/Project.cpp b/src/Project.cpp index 1fb7a2e..89501cd 100644 --- a/src/Project.cpp +++ b/src/Project.cpp @@ -63,6 +63,12 @@ Project Project::load(const QString& path) { Project p; p.videoSourceType = root["videoSourceType"].toString(); p.ndiSource = root["ndiSource"].toString(); + 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(); + } p.decklinkDevice = root["decklinkDevice"].toString(); p.decklinkConnection = root["decklinkConnection"].toString(); p.decklinkAllow10Bit = root["decklinkAllow10Bit"].toBool(true); @@ -126,6 +132,11 @@ void Project::save(const QString& path) const { QJsonObject root; root["videoSourceType"] = videoSourceType; root["ndiSource"] = ndiSource; + 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 a1c7eb2..0e31c96 100644 --- a/src/Project.h +++ b/src/Project.h @@ -4,6 +4,7 @@ #include #include #include +#include struct TrackerConfig { int id = 1; @@ -43,9 +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; + 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 288127d..e252eac 100644 --- a/src/ui/StreamSourcePanel.cpp +++ b/src/ui/StreamSourcePanel.cpp @@ -5,10 +5,13 @@ #include #include #include +#include #include #include #include #include +#include +#include #include "../DeckLinkCapture.h" #if WEBCAM_AVAILABLE # include @@ -44,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_); @@ -57,8 +61,11 @@ class NdiSourceTab : public VideoSourceTab { layout->addStretch(); + emitCurrentSourceEndpoint(); + connect(refreshBtn_, &QPushButton::clicked, this, &NdiSourceTab::refreshSources); connect(combo_, &QComboBox::currentTextChanged, this, [this](const QString& text) { + emitCurrentSourceEndpoint(); if (!settingSource_ && !text.isEmpty()) emit sourceActivated(text); }); @@ -87,6 +94,13 @@ class NdiSourceTab : public VideoSourceTab { if (shouldActivate && !combo_->currentText().isEmpty()) emit sourceActivated(combo_->currentText()); + emitCurrentSourceEndpoint(); + }); + connect(ndi_, &NdiReceiver::sourceEndpointChanged, + this, [this](const QString& sourceName, const QString& urlAddress) { + sourceEndpoints_[sourceName] = urlAddress; + if (sourceName == combo_->currentText()) + emitCurrentSourceEndpoint(); }); } @@ -111,12 +125,23 @@ class NdiSourceTab : public VideoSourceTab { combo_->setCurrentIndex(combo_->count() - 1); } settingSource_ = false; + emitCurrentSourceEndpoint(); } + +signals: + void sourceEndpointChanged(const QString& sourceName, const QString& urlAddress); + private: + void emitCurrentSourceEndpoint() { + const QString source = combo_->currentText(); + emit sourceEndpointChanged(source, sourceEndpoints_.value(source)); + } + NdiReceiver* ndi_; QComboBox* combo_; QPushButton* refreshBtn_; + QMap sourceEndpoints_; bool settingSource_ = false; }; @@ -131,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_); @@ -511,7 +537,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 +563,8 @@ StreamSourcePanel::StreamSourcePanel(NdiReceiver* ndi, QWidget* parent) connect(ndiTab_, &VideoSourceTab::sourceActivated, this, &StreamSourcePanel::ndiSourceSelected); + connect(ndiTab_, &NdiSourceTab::sourceEndpointChanged, + this, &StreamSourcePanel::ndiSourceEndpointChanged); connect(webcamTab_, &VideoSourceTab::sourceActivated, this, &StreamSourcePanel::webcamSourceSelected); diff --git a/src/ui/StreamSourcePanel.h b/src/ui/StreamSourcePanel.h index 4b16724..f2334eb 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 { @@ -23,10 +24,11 @@ class StreamSourcePanel : public QWidget { void webcamSourceSelected(const QString& device); void decklinkSourceSelected(const QString& deviceId, const QString& connection, uint32_t displayMode, bool allow10Bit); + void ndiSourceEndpointChanged(const QString& sourceName, const QString& urlAddress); 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..5ec5a6f --- /dev/null +++ b/tests/test_marshall_cv370_controller.cpp @@ -0,0 +1,90 @@ +#include +#include +#include +#include +#include +#include "MarshallCv370Controller.h" +#include "Project.h" + +class MarshallCv370ControllerTest : public QObject { + Q_OBJECT + +private slots: + void setNightModeBuildsDaylightRequest(); + void setNightModeBuildsNightRequest(); + void hostNormalizationAcceptsSchemeAndTrailingSlash(); + void extractsHostFromNdiUrlAddress(); + void buildsDetectionRequest(); + void projectSaveUsesCameraControlObject(); +}; + +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")); +} + +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")); +} + +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); +} + +QTEST_MAIN(MarshallCv370ControllerTest) +#include "test_marshall_cv370_controller.moc"