Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 != '' }}
Expand All @@ -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
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 `
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
161 changes: 161 additions & 0 deletions src/CameraControl.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#include "CameraControl.h"

#include "MarshallCv370Controller.h"

#include <QCheckBox>
#include <QComboBox>
#include <QHBoxLayout>
#include <QJsonObject>
#include <QLabel>
#include <QSizePolicy>
#include <QStackedWidget>
#include <QVBoxLayout>

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();
}
Loading
Loading