Skip to content
Merged
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
37 changes: 37 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Pre-commit Checks

on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main, dev ]

jobs:
pre-commit:
name: Run pre-commit hooks
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'

- name: Install pre-commit
run: |
python -m pip install --upgrade pip
pip install pre-commit

- name: Cache pre-commit environments
uses: actions/cache@v3
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
restore-keys: |
pre-commit-${{ runner.os }}-

- name: Run pre-commit
run: pre-commit run --all-files --show-diff-on-failure
2 changes: 1 addition & 1 deletion src/Capture/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
add_library(sh3ds_capture STATIC FileFrameSource.cpp FramePreprocessor.cpp ScreenDetector.cpp VideoFrameSource.cpp)
add_library(sh3ds_capture STATIC FileFrameSource.cpp FramePreprocessor.cpp MjpegFrameSource.cpp ScreenDetector.cpp VideoFrameSource.cpp)
add_library(SH3DS::Capture ALIAS sh3ds_capture)

target_include_directories(
Expand Down
140 changes: 140 additions & 0 deletions src/Capture/MjpegFrameSource.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#include "MjpegFrameSource.h"

#include "Kappa/Logger.h"

#include <chrono>
#include <thread>

namespace SH3DS::Capture
{
MjpegFrameSource::MjpegFrameSource(Core::CameraConfig config)
: uri(std::move(config.uri)),
reconnectDelayMs(config.reconnectDelayMs),
maxReconnectAttempts(config.maxReconnectAttempts),
grabTimeoutMs(config.grabTimeoutMs)
{
}

bool MjpegFrameSource::Open()
{
std::lock_guard<std::mutex> lock(mutex);

permanentlyFailed = false;
currentReconnectAttempts = 0;
frameCounter = 0;

capture.set(cv::CAP_PROP_OPEN_TIMEOUT_MSEC, static_cast<double>(grabTimeoutMs));
capture.set(cv::CAP_PROP_READ_TIMEOUT_MSEC, static_cast<double>(grabTimeoutMs));

if (!capture.open(uri))
{
LOG_ERROR("MjpegFrameSource: Failed to open URI: {}", uri);
isOpen = false;
return false;
}

isOpen = true;
LOG_INFO("MjpegFrameSource: Opened {}", uri);
return true;
}

void MjpegFrameSource::Close()
{
std::lock_guard<std::mutex> lock(mutex);

capture.release();
isOpen = false;
frameCounter = 0;
currentReconnectAttempts = 0;
permanentlyFailed = false;
}

std::optional<Core::Frame> MjpegFrameSource::Grab()
{
std::lock_guard<std::mutex> lock(mutex);

if (!isOpen || permanentlyFailed)
{
return std::nullopt;
}

cv::Mat image;
if (!capture.read(image) || image.empty())
{
LOG_WARN("MjpegFrameSource: Read failed, attempting reconnect...");
// mutex is already held — TryReconnect must not re-lock
if (!TryReconnect())
{
return std::nullopt;
}
if (!capture.read(image) || image.empty())
{
return std::nullopt;
}
}

Core::Frame frame;
frame.image = image;
frame.metadata.sequenceNumber = frameCounter;
frame.metadata.captureTime = std::chrono::steady_clock::now();
frame.metadata.sourceWidth = image.cols;
frame.metadata.sourceHeight = image.rows;

++frameCounter;
return frame;
}

bool MjpegFrameSource::IsOpen() const
{
std::lock_guard<std::mutex> lock(mutex);
return isOpen && !permanentlyFailed;
}

std::string MjpegFrameSource::Describe() const
{
return "MjpegFrameSource(" + uri + ")";
}

bool MjpegFrameSource::TryReconnect()
{
// Called with mutex already held.
for (int attempt = 1; attempt <= maxReconnectAttempts; ++attempt)
{
currentReconnectAttempts = attempt;
LOG_WARN("MjpegFrameSource: reconnecting {}/{} to {}", attempt, maxReconnectAttempts, uri);

capture.release();

if (reconnectDelayMs > 0)
{
// Temporarily release the mutex while sleeping to avoid blocking other threads.
// We re-check state after re-acquiring — but since we are the only producer this
// is safe for the single-slot live capture pattern.
mutex.unlock();
std::this_thread::sleep_for(std::chrono::milliseconds(reconnectDelayMs));
mutex.lock();
}

capture.set(cv::CAP_PROP_OPEN_TIMEOUT_MSEC, static_cast<double>(grabTimeoutMs));
capture.set(cv::CAP_PROP_READ_TIMEOUT_MSEC, static_cast<double>(grabTimeoutMs));

if (capture.open(uri))
{
LOG_INFO("MjpegFrameSource: reconnected to {} on attempt {}", uri, attempt);
currentReconnectAttempts = 0;
return true;
}
}

LOG_ERROR("MjpegFrameSource: exhausted {} reconnect attempts for {}", maxReconnectAttempts, uri);
isOpen = false;
permanentlyFailed = true;
return false;
}

std::unique_ptr<FrameSource> MjpegFrameSource::CreateMjpegFrameSource(const Core::CameraConfig &config)
{
return std::make_unique<MjpegFrameSource>(config);
}

} // namespace SH3DS::Capture
64 changes: 64 additions & 0 deletions src/Capture/MjpegFrameSource.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#pragma once

#include "FrameSource.h"

#include "Core/Config.h"

#include <opencv2/videoio.hpp>

#include <atomic>
#include <memory>
#include <mutex>
#include <string>

namespace SH3DS::Capture
{
/**
* @brief Reads frames from a live MJPEG stream via cv::VideoCapture (ffmpeg backend).
*
* Implements FrameSource only — does not implement FrameSeeker (live streams
* are not seekable). On read failure, attempts reconnection up to
* maxReconnectAttempts times before entering permanently-failed state.
*/
class MjpegFrameSource : public FrameSource
{
public:
/**
* @brief Constructs a new MjpegFrameSource.
* @param config Camera configuration (uri, reconnect settings, timeout).
*/
explicit MjpegFrameSource(Core::CameraConfig config);

bool Open() override;
void Close() override;
std::optional<Core::Frame> Grab() override;
bool IsOpen() const override;
std::string Describe() const override;

/**
* @brief Factory — creates a MjpegFrameSource wrapped in a FrameSource unique_ptr.
* @param config Camera configuration.
* @return Non-null unique_ptr to a FrameSource.
*/
static std::unique_ptr<FrameSource> CreateMjpegFrameSource(const Core::CameraConfig &config);

private:
/**
* @brief Attempts to reconnect up to maxReconnectAttempts times.
* @return True if reconnection succeeded.
*/
bool TryReconnect();

std::string uri; ///< Stream URI (HTTP URL or local file path)
int reconnectDelayMs; ///< Delay between reconnect attempts in ms
int maxReconnectAttempts; ///< Maximum number of reconnect attempts
int grabTimeoutMs; ///< Open/read timeout in ms (passed to VideoCapture)
cv::VideoCapture capture; ///< OpenCV capture object
mutable std::mutex mutex; ///< Guards capture and frame counter
size_t frameCounter = 0; ///< Monotonically increasing sequence number
int currentReconnectAttempts = 0; ///< Reconnect attempts used in last failure run
bool isOpen = false; ///< Whether capture is currently open
bool permanentlyFailed = false; ///< True after exhausting all reconnect attempts
};

} // namespace SH3DS::Capture
3 changes: 3 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ target_link_libraries(TestConfigBottomScreen PRIVATE SH3DS::Core)
sh3ds_add_test(TestVideoFrameSource unit/TestVideoFrameSource.cpp)
target_link_libraries(TestVideoFrameSource PRIVATE SH3DS::Capture)

sh3ds_add_test(TestMjpegFrameSource unit/TestMjpegFrameSource.cpp)
target_link_libraries(TestMjpegFrameSource PRIVATE SH3DS::Capture)

sh3ds_add_test(TestScreenDetector unit/TestScreenDetector.cpp)
target_link_libraries(TestScreenDetector PRIVATE SH3DS::Capture)

Expand Down
Loading
Loading