diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..348afee --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -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 diff --git a/src/Capture/CMakeLists.txt b/src/Capture/CMakeLists.txt index 22bb3df..0d29391 100644 --- a/src/Capture/CMakeLists.txt +++ b/src/Capture/CMakeLists.txt @@ -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( diff --git a/src/Capture/MjpegFrameSource.cpp b/src/Capture/MjpegFrameSource.cpp new file mode 100644 index 0000000..6703423 --- /dev/null +++ b/src/Capture/MjpegFrameSource.cpp @@ -0,0 +1,140 @@ +#include "MjpegFrameSource.h" + +#include "Kappa/Logger.h" + +#include +#include + +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 lock(mutex); + + permanentlyFailed = false; + currentReconnectAttempts = 0; + frameCounter = 0; + + capture.set(cv::CAP_PROP_OPEN_TIMEOUT_MSEC, static_cast(grabTimeoutMs)); + capture.set(cv::CAP_PROP_READ_TIMEOUT_MSEC, static_cast(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 lock(mutex); + + capture.release(); + isOpen = false; + frameCounter = 0; + currentReconnectAttempts = 0; + permanentlyFailed = false; + } + + std::optional MjpegFrameSource::Grab() + { + std::lock_guard 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 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(grabTimeoutMs)); + capture.set(cv::CAP_PROP_READ_TIMEOUT_MSEC, static_cast(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 MjpegFrameSource::CreateMjpegFrameSource(const Core::CameraConfig &config) + { + return std::make_unique(config); + } + +} // namespace SH3DS::Capture diff --git a/src/Capture/MjpegFrameSource.h b/src/Capture/MjpegFrameSource.h new file mode 100644 index 0000000..a866629 --- /dev/null +++ b/src/Capture/MjpegFrameSource.h @@ -0,0 +1,64 @@ +#pragma once + +#include "FrameSource.h" + +#include "Core/Config.h" + +#include + +#include +#include +#include +#include + +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 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 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5dc1cd2..d554bd0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/unit/TestMjpegFrameSource.cpp b/tests/unit/TestMjpegFrameSource.cpp new file mode 100644 index 0000000..9f43c59 --- /dev/null +++ b/tests/unit/TestMjpegFrameSource.cpp @@ -0,0 +1,144 @@ +#include "Capture/FrameSeeker.h" +#include "Capture/MjpegFrameSource.h" + +#include "Core/Config.h" + +#include +#include + +#include + +#include + +namespace +{ + + class MjpegFrameSourceTest : public ::testing::Test + { + protected: + void SetUp() override + { + videoPath = std::filesystem::temp_directory_path() / "sh3ds_test_mjpeg.avi"; + + cv::VideoWriter writer( + videoPath.string(), cv::VideoWriter::fourcc('M', 'J', 'P', 'G'), 10.0, cv::Size(64, 48)); + + if (!writer.isOpened()) + { + GTEST_SKIP() << "Cannot create test MJPEG video (no codec support)"; + } + + for (int i = 0; i < 5; ++i) + { + cv::Mat frame(48, 64, CV_8UC3, cv::Scalar(i * 40, 100, 200)); + writer.write(frame); + } + writer.release(); + + if (!std::filesystem::exists(videoPath) || std::filesystem::file_size(videoPath) == 0) + { + GTEST_SKIP() << "Test MJPEG video was not created successfully"; + } + } + + void TearDown() override + { + std::filesystem::remove(videoPath); + } + + SH3DS::Core::CameraConfig MakeConfig(const std::string &uri) const + { + SH3DS::Core::CameraConfig cfg; + cfg.type = "mjpeg"; + cfg.uri = uri; + cfg.reconnectDelayMs = 0; + cfg.maxReconnectAttempts = 0; + cfg.grabTimeoutMs = 2000; + return cfg; + } + + std::filesystem::path videoPath; + }; + +} // namespace + +TEST_F(MjpegFrameSourceTest, OpenFailsOnUnreachableUri) +{ + SH3DS::Capture::MjpegFrameSource source(MakeConfig("http://127.0.0.1:1/video")); + EXPECT_FALSE(source.Open()); + EXPECT_FALSE(source.IsOpen()); +} + +TEST_F(MjpegFrameSourceTest, OpenSucceedsOnLocalFileUri) +{ + SH3DS::Capture::MjpegFrameSource source(MakeConfig(videoPath.string())); + EXPECT_TRUE(source.Open()); + EXPECT_TRUE(source.IsOpen()); +} + +TEST_F(MjpegFrameSourceTest, GrabReturnsFrameWithCorrectMetadata) +{ + SH3DS::Capture::MjpegFrameSource source(MakeConfig(videoPath.string())); + source.Open(); + + auto frame = source.Grab(); + ASSERT_TRUE(frame.has_value()); + EXPECT_FALSE(frame->image.empty()); + EXPECT_EQ(frame->metadata.sequenceNumber, 0u); + EXPECT_GT(frame->metadata.captureTime.time_since_epoch().count(), 0); + + auto frame2 = source.Grab(); + ASSERT_TRUE(frame2.has_value()); + EXPECT_EQ(frame2->metadata.sequenceNumber, 1u); +} + +TEST_F(MjpegFrameSourceTest, GrabReturnsNulloptWhenExhausted) +{ + SH3DS::Capture::MjpegFrameSource source(MakeConfig(videoPath.string())); + source.Open(); + + // Drain all 5 frames + for (int i = 0; i < 5; ++i) + { + source.Grab(); + } + + auto frame = source.Grab(); + EXPECT_FALSE(frame.has_value()); +} + +TEST_F(MjpegFrameSourceTest, CloseReleasesCapture) +{ + SH3DS::Capture::MjpegFrameSource source(MakeConfig(videoPath.string())); + source.Open(); + EXPECT_TRUE(source.IsOpen()); + + source.Close(); + EXPECT_FALSE(source.IsOpen()); + + auto frame = source.Grab(); + EXPECT_FALSE(frame.has_value()); +} + +TEST_F(MjpegFrameSourceTest, DescribeContainsUri) +{ + const std::string uri = videoPath.string(); + SH3DS::Capture::MjpegFrameSource source(MakeConfig(uri)); + + std::string desc = source.Describe(); + EXPECT_NE(desc.find("MjpegFrameSource"), std::string::npos); + EXPECT_NE(desc.find(uri), std::string::npos); +} + +TEST_F(MjpegFrameSourceTest, FactoryReturnsNonNullPtr) +{ + auto source = SH3DS::Capture::MjpegFrameSource::CreateMjpegFrameSource(MakeConfig(videoPath.string())); + ASSERT_NE(source, nullptr); +} + +TEST_F(MjpegFrameSourceTest, DoesNotImplementFrameSeeker) +{ + SH3DS::Capture::MjpegFrameSource source(MakeConfig(videoPath.string())); + SH3DS::Capture::FrameSeeker *seeker = dynamic_cast(&source); + EXPECT_EQ(seeker, nullptr); +}