Program Listing for File video_manager.hpp#

Return to documentation for file (src/video/video_manager.hpp)

#pragma once
#include <QImage>
#include <QObject>
#include <memory>

#include "core/settings.hpp"
#include "video/video_frame.hpp"

namespace mosaic {

/// @brief Orchestrates one VideoGrabber + VideoEncoder pair per configured camera.
///
/// VideoManager owns the complete video pipeline for a session.  It matches
/// the lifecycle of the cameras: open them once at startup, then start/stop
/// recording repeatedly without reopening.
///
/// @par Thread model
/// Each camera runs two background threads:
/// - **VideoGrabber** — grabs frames from Pylon (or generates test patterns).
/// - **VideoEncoder** — encodes frames via FFmpeg and writes the timestamp CSV.
///
/// Frames are passed between threads through a lock-free SPSC RingBuffer.
/// All *public* methods of VideoManager itself must be called from the main
/// thread.
///
/// @par Lifecycle
/// @code{.cpp}
/// VideoManager vm;
/// int opened = vm.open(settings.video);   // opens cameras once
///
/// // Start/stop can repeat throughout the session
/// vm.start("recordings/2026-06-04_14-32-05", "video", settings.video);
/// // ... recording in progress ...
/// vm.stop();   // blocks until all encoders have flushed and closed their files
///
/// vm.close();  // release camera handles (called by destructor)
/// @endcode
///
/// @see VideoGrabber, VideoEncoder, FrameTimestampWriter, RecordManager
class VideoManager : public QObject {
    Q_OBJECT
   public:
    explicit VideoManager(QObject* parent = nullptr);
    ~VideoManager() override;

    /// @brief Opens camera devices (or stub generators) for all configured cameras.
    ///
    /// @param settings  Video settings from AppSettings.  The @c cameras vector
    ///                  determines how many devices are opened.
    /// @returns         The number of cameras successfully opened.  May be less
    ///                  than @c settings.cameras.size() if some devices fail.
    int open(const VideoSettings& settings);

    /// @brief Closes all open camera handles.
    ///
    /// Calls stop() first if a recording is active.
    void close();

    /// @brief Starts the grab loop for all cameras without creating encoders.
    ///
    /// Call this after open() to see live preview frames in the QML monitor
    /// before any recording session begins.  Safe to call if grabbers are
    /// already running (no-op for those cameras).
    void start_preview();

    /// @brief Starts grabbing and encoding for all open cameras.
    ///
    /// Output files:
    /// - @c \<sessionDir\>/\<videoBasename\>_N.mp4
    /// - @c \<sessionDir\>/timestamps_camN.csv
    ///
    /// @param sessionDir    Absolute path to the session folder (must exist).
    /// @param videoBasename Basename for video files (e.g. @c "video").
    /// @param settings      Video settings (codec, preset, per-camera FPS/resolution).
    void start(const QString& sessionDir, const QString& videoBasename,
               const VideoSettings& settings);

    /// @brief Stops all grabbers and encoders, flushing and closing every file.
    ///
    /// Blocks until all encoder threads have exited (up to 10 s per camera).
    void stop();

    /// @brief Re-applies exposure/gain/gamma/black-level/white-balance/
    /// auto-target/digital-shift for one camera to already-open hardware,
    /// without stopping or reopening it.
    ///
    /// Call this after the caller has mutated the corresponding
    /// CameraParameters in place (e.g. via the settings UI). Structural
    /// parameters (resolution, pixel format, frame rate, hardware trigger)
    /// still require a full close()+open() to take effect and are not
    /// touched by this call.
    ///
    /// @param configIndex  Position in the *configured* settings.cameras
    ///                     array (VideoManager::CameraUnit::configIndex),
    ///                     not the camera's position among successfully
    ///                     opened units. No-op if no open unit matches.
    void apply_live_params(int configIndex);

    /// @brief Requests one full-resolution frame from the given camera for
    /// room (extrinsic) calibration — see VideoGrabber::request_calibration_frame().
    /// Delivered asynchronously via calibration_frame_ready(), echoing token
    /// back verbatim so a caller that requests one frame per "shot" can
    /// reject a reply for a shot it has already moved past. No-op if no
    /// open unit matches configIndex.
    void request_calibration_frame(int configIndex, uint64_t token = 0);

    /// @returns @c true while a recording session is active.
    [[nodiscard]] bool is_recording() const;

    /// @returns @c true while a live preview session is active (started via
    /// start_preview(), not yet stopped by start()/close()). Used to guard
    /// actions that would race with a running ActionCommandTicker, which
    /// touches Pylon's CTlFactory from a background thread for as long as
    /// either preview or recording keeps it alive.
    [[nodiscard]] bool is_previewing() const;

    /// @returns The number of cameras that were successfully opened.
    [[nodiscard]] int camera_count() const;

    /// @returns Total frames encoded across all cameras since the last start().
    [[nodiscard]] int64_t total_frames_encoded() const;

    /// @returns Total frames dropped (ring buffer overflow) since last start().
    [[nodiscard]] int64_t total_frames_dropped() const;

    /// @brief Per-camera real-time performance snapshot.
    struct CameraStats {
        double fps                 = 0.0; ///< Measured grab rate (frames/s).
        int64_t framesGrabbed      = 0;   ///< Total frames grabbed since start().
        int64_t framesEncoded      = 0;   ///< Total frames encoded since start().
        int64_t framesDropped      = 0;   ///< Frames lost to ring-buffer overflow.
        int ringFillPct            = 0;   ///< Ring buffer fill level, 0–100.
        bool grabberRunning        = false;
        int64_t lastFrameElapsedNs = -1; ///< elapsed_ns() of the most recent frame, -1 if none yet.
    };

    /// @brief Returns a performance snapshot for one camera.
    ///
    /// @param index  Zero-based camera index.  Returns a zeroed struct if out of range.
    [[nodiscard]] CameraStats camera_stats(int index) const;

   signals:
    /// Emitted on the main thread when a camera device is successfully opened.
    void camera_opened(int cameraIndex, int width, int height, double fps);

    /// Emitted when a camera device is closed.
    void camera_closed(int cameraIndex);

    /// Emitted each time a frame is dropped because the ring buffer was full.
    /// @param cameraIndex  Which camera dropped the frame.
    /// @param frameId      The frame counter value of the dropped frame.
    void frame_dropped(int cameraIndex, int64_t frameId);

    /// Emitted when a camera grab or encode error occurs.
    void camera_error(int cameraIndex, QString message);

    /// Emitted when all encoders have finished (files closed and flushed).
    void recording_stopped();

    /// Throttled (~15 fps) BGR preview for live QML display.
    void frame_preview(int cameraIndex, QImage frame);

    /// Full-resolution frame delivered in response to request_calibration_frame().
    /// token is whatever was passed to request_calibration_frame().
    void calibration_frame_ready(int cameraIndex, QImage frame, uint64_t token);

    /// Passthrough of VideoGrabber::action_command_capability() — reports
    /// whether a camera's firmware supports GigE Vision Action Command
    /// triggering, once probed at open() time. Only fires for cameras that
    /// actually requested Action1 triggering (hwTriggerEnabled &&
    /// hwTriggerSource == "Action1").
    void action_command_capability(int cameraIndex, bool supported);

   private slots:
    void on_encoder_stopped(int cameraIndex, int64_t frames);

   private:
    // Arms every unit's grabber (start_grabbing()) first, then, for every
    // Action1-ready unit, starts a background ActionCommandTicker that
    // fires one GigE Vision IssueActionCommand() per frame, continuously,
    // for as long as the ticker runs — shared by start_preview() and
    // start(), since a camera configured for Action1 hangs identically
    // (produces zero frames) in either mode until action commands start
    // arriving. See gige_action_command.hpp.
    void arm_and_fire_action_commands();

    // Stops and destroys the current ActionCommandTicker, if any. Called
    // from every path that stops grabbers (stop(), close(), and start()'s
    // preview-teardown step) so the ticker never outlives the cameras it's
    // firing at. No-op if no ticker is running.
    void stop_action_ticker();

    struct Impl;
    std::unique_ptr<Impl> d;
};

} // namespace mosaic