From c841c1f4990aac3737da2ebd3e365c94f4789a88 Mon Sep 17 00:00:00 2001 From: wy Date: Fri, 15 May 2026 02:42:50 +0800 Subject: [PATCH 01/10] feat: add video file writing support for macOS and Windows Add VideoWriter class to encode frames into MP4/MOV video files using system frameworks only (AVAssetWriter on macOS, IMFMediaSink on Windows). - C++ API (VideoWriter) and pure C API (ccap_video_writer_*) - HEVC codec with automatic H.264 fallback - Supports BGR24, BGRA32, I420, NV12 pixel formats - New CMake option CCAP_ENABLE_VIDEO_WRITER (ON by default) - 15 unit tests covering lifecycle, frame writing, codec fallback Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 23 ++ include/ccap_c.h | 6 + include/ccap_def.h | 17 ++ include/ccap_writer.h | 104 +++++++++ include/ccap_writer_c.h | 111 ++++++++++ src/ccap_c.cpp | 11 + src/ccap_utils.cpp | 10 + src/ccap_writer.mm | 121 +++++++++++ src/ccap_writer_apple.mm | 372 ++++++++++++++++++++++++++++++++ src/ccap_writer_c.cpp | 117 ++++++++++ src/ccap_writer_imp.h | 33 +++ src/ccap_writer_windows.cpp | 338 +++++++++++++++++++++++++++++ tests/CMakeLists.txt | 52 +++++ tests/test_video_writer.cpp | 414 ++++++++++++++++++++++++++++++++++++ 14 files changed, 1729 insertions(+) create mode 100644 include/ccap_writer.h create mode 100644 include/ccap_writer_c.h create mode 100644 src/ccap_writer.mm create mode 100644 src/ccap_writer_apple.mm create mode 100644 src/ccap_writer_c.cpp create mode 100644 src/ccap_writer_imp.h create mode 100644 src/ccap_writer_windows.cpp create mode 100644 tests/test_video_writer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index caa431da..cb9d8155 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,8 +132,27 @@ if (NOT CCAP_ENABLE_FILE_PLAYBACK) message(STATUS "ccap: Video file playback support disabled") endif () +# Video writer sources (Windows and macOS only) +option(CCAP_ENABLE_VIDEO_WRITER "Enable video file writing support (Windows/macOS)" ON) +if (CCAP_ENABLE_VIDEO_WRITER AND (APPLE OR WIN32)) + # Exclude writer sources from main glob to avoid double-compilation (platform impl included via #include) + list(FILTER LIB_SOURCE EXCLUDE REGEX ".*ccap_writer_apple.*") + list(FILTER LIB_SOURCE EXCLUDE REGEX ".*ccap_writer_windows.*") + list(FILTER LIB_SOURCE EXCLUDE REGEX ".*ccap_writer_c\..*$") + list(FILTER LIB_SOURCE EXCLUDE REGEX ".*ccap_writer\..*$") + list(APPEND LIB_SOURCE + ${CMAKE_CURRENT_SOURCE_DIR}/src/ccap_writer.mm + ${CMAKE_CURRENT_SOURCE_DIR}/src/ccap_writer_c.cpp + ) + message(STATUS "ccap: Video file writing support enabled") +else () + message(STATUS "ccap: Video file writing support disabled (unsupported platform or disabled)") +endif () + if (APPLE) file(GLOB LIB_SOURCE_MAC ${CMAKE_CURRENT_SOURCE_DIR}/src/*.mm) + list(FILTER LIB_SOURCE_MAC EXCLUDE REGEX ".*ccap_writer_apple.*") + list(FILTER LIB_SOURCE_MAC EXCLUDE REGEX ".*ccap_writer\.mm$") message(STATUS "ccap: Using Objective-C++ for macOS: ${LIB_SOURCE_MAC}") list(APPEND LIB_SOURCE ${LIB_SOURCE_MAC}) endif () @@ -207,6 +226,10 @@ else () message(STATUS "ccap: Video file playback support disabled") endif () +if (CCAP_ENABLE_VIDEO_WRITER AND (APPLE OR WIN32)) + target_compile_definitions(ccap PUBLIC CCAP_ENABLE_VIDEO_WRITER=1) +endif () + # Configure shared library export definitions if (CCAP_BUILD_SHARED) target_compile_definitions(ccap PUBLIC CCAP_SHARED=1) diff --git a/include/ccap_c.h b/include/ccap_c.h index 06602f8b..8b6cdab5 100644 --- a/include/ccap_c.h +++ b/include/ccap_c.h @@ -93,6 +93,12 @@ typedef enum { CCAP_ERROR_FILE_OPEN_FAILED = 0x5001, /**< Failed to open video file */ CCAP_ERROR_UNSUPPORTED_VIDEO_FORMAT = 0x5002, /**< Video format is not supported */ CCAP_ERROR_SEEK_FAILED = 0x5003, /**< Seek operation failed */ + /* Video writer error codes */ + CCAP_ERROR_WRITER_OPEN_FAILED = 0x6001, /**< Failed to open video writer */ + CCAP_ERROR_WRITER_WRITE_FAILED = 0x6002, /**< Failed to write frame */ + CCAP_ERROR_WRITER_CLOSE_FAILED = 0x6003, /**< Failed to finalize file */ + CCAP_ERROR_WRITER_NOT_OPENED = 0x6004, /**< Writer not opened */ + CCAP_ERROR_UNSUPPORTED_CODEC = 0x6005, /**< Codec not supported on this platform */ CCAP_ERROR_INTERNAL_ERROR = 0x9999, /**< Unknown or internal error */ } CcapErrorCode; diff --git a/include/ccap_def.h b/include/ccap_def.h index 8f0288d9..2df3f465 100644 --- a/include/ccap_def.h +++ b/include/ccap_def.h @@ -309,6 +309,23 @@ enum class ErrorCode { /// Seek operation failed SeekFailed = 0x5003, + // ============== Video Writer Errors ============== + + /// Failed to open video writer + WriterOpenFailed = 0x6001, + + /// Failed to write frame + WriterWriteFailed = 0x6002, + + /// Failed to finalize file + WriterCloseFailed = 0x6003, + + /// Writer not opened + WriterNotOpened = 0x6004, + + /// Codec not supported on this platform + UnsupportedCodec = 0x6005, + /// Unknown or internal error InternalError = 0x9999, }; diff --git a/include/ccap_writer.h b/include/ccap_writer.h new file mode 100644 index 00000000..bdb00f8e --- /dev/null +++ b/include/ccap_writer.h @@ -0,0 +1,104 @@ +/** + * @file ccap_writer.h + * @author wysaid (this@wysaid.org) + * @brief Video writer header file for ccap. + * @date 2025-05 + * + * @note Requires CCAP_ENABLE_VIDEO_WRITER to be defined. + * Only available on Windows and macOS. + */ + +#ifndef __cplusplus +#error "ccap_writer.h is for C++ only. For C language, please use ccap_writer_c.h instead." +#endif + +#pragma once +#ifndef CCAP_WRITER_H +#define CCAP_WRITER_H + +#include "ccap_def.h" + +#include +#include + +namespace ccap { + +/** + * @brief Video codec for encoding. + */ +enum class VideoCodec { + HEVC, ///< H.265 / HEVC (preferred, better compression) + H264, ///< H.264 / AVC (fallback, wider compatibility) +}; + +/** + * @brief Video container format. + */ +enum class VideoFormat { + MP4, ///< MP4 container + MOV, ///< MOV container +}; + +/** + * @brief Configuration for video writer. + */ +struct WriterConfig { + VideoCodec codec = VideoCodec::HEVC; ///< Preferred codec; auto-fallback to H.264 if unavailable + VideoFormat container = VideoFormat::MP4; + uint32_t width = 0; ///< Frame width in pixels + uint32_t height = 0; ///< Frame height in pixels + double frameRate = 30.0; ///< Target frame rate; 0 = variable rate + uint64_t bitRate = 5'000'000; ///< Target bit rate in bits/s; 0 = auto +}; + +/** + * @brief Video file writer. Captures frames and encodes them into a video file. + * @note This class is not thread-safe. Use it in a single thread or protect with a mutex. + */ +class CCAP_EXPORT VideoWriter { +public: + VideoWriter(); + ~VideoWriter(); + + /// Move-only + VideoWriter(VideoWriter&&) noexcept; + VideoWriter& operator=(VideoWriter&&) noexcept; + VideoWriter(const VideoWriter&) = delete; + VideoWriter& operator=(const VideoWriter&) = delete; + + /** + * @brief Open writer to a file path. + * @param filePath Output file path (e.g., "output.mp4") + * @param config Writer configuration (width, height, codec, etc.) + * @return true on success, false on failure. + */ + bool open(std::string_view filePath, const WriterConfig& config); + + /// Close and finalize the file. + void close(); + bool isOpened() const; + + /** + * @brief Write a single frame. + * @param frame The video frame to write. Pixel format will be converted to NV12 internally. + * @param timestampNs Optional timestamp in nanoseconds. If 0, auto-increment based on frameRate. + * @return true on success, false on failure. + */ + bool writeFrame(const VideoFrame& frame, uint64_t timestampNs = 0); + + /// Query the actual codec being used (may differ from config due to fallback). + VideoCodec actualCodec() const; + + uint32_t width() const; + uint32_t height() const; + double frameRate() const; + + struct Impl; + +private: + void* m_impl; +}; + +} // namespace ccap + +#endif // CCAP_WRITER_H diff --git a/include/ccap_writer_c.h b/include/ccap_writer_c.h new file mode 100644 index 00000000..e913291b --- /dev/null +++ b/include/ccap_writer_c.h @@ -0,0 +1,111 @@ +/** + * @file ccap_writer_c.h + * @author wysaid (this@wysaid.org) + * @brief Pure C interface for ccap video writer. + * @date 2025-05 + * + * @note Requires CCAP_ENABLE_VIDEO_WRITER to be defined. + * Only available on Windows and macOS. + */ + +#pragma once +#ifndef CCAP_WRITER_C_H +#define CCAP_WRITER_C_H + +#include "ccap_c.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ========== Forward Declarations ========== */ + +/** @brief Opaque pointer to ccap::VideoWriter C++ object */ +typedef struct CcapVideoWriter CcapVideoWriter; + +/* ========== Enumerations ========== */ + +/** @brief Video codec enumeration */ +typedef enum { + CCAP_VIDEO_CODEC_HEVC = 0, ///< H.265 / HEVC (preferred) + CCAP_VIDEO_CODEC_H264 = 1, ///< H.264 / AVC (fallback) +} CcapVideoCodec; + +/** @brief Video container format */ +typedef enum { + CCAP_VIDEO_FORMAT_MP4 = 0, + CCAP_VIDEO_FORMAT_MOV = 1, +} CcapVideoFormat; + +/* ========== Data Structures ========== */ + +/** @brief Video writer configuration */ +typedef struct { + CcapVideoCodec codec; ///< Preferred codec + CcapVideoFormat container; ///< Container format + uint32_t width; ///< Frame width + uint32_t height; ///< Frame height + double frameRate; ///< Target frame rate (0 = variable) + uint64_t bitRate; ///< Target bit rate in bits/s (0 = auto) +} CcapWriterConfig; + +/* ========== Writer Lifecycle ========== */ + +/** + * @brief Create a new video writer instance + * @return Pointer to CcapVideoWriter instance, or NULL on failure + */ +CCAP_EXPORT CcapVideoWriter* ccap_video_writer_create(void); + +/** + * @brief Destroy a video writer instance and finalize the output file + * @param writer Pointer to CcapVideoWriter instance + */ +CCAP_EXPORT void ccap_video_writer_destroy(CcapVideoWriter* writer); + +/** + * @brief Open writer to a file path + * @param writer Pointer to CcapVideoWriter instance + * @param filePath Output file path (e.g., "output.mp4") + * @param config Writer configuration + * @return true on success, false on failure + */ +CCAP_EXPORT bool ccap_video_writer_open(CcapVideoWriter* writer, const char* filePath, + const CcapWriterConfig* config); + +/** + * @brief Close and finalize the output file + * @param writer Pointer to CcapVideoWriter instance + */ +CCAP_EXPORT void ccap_video_writer_close(CcapVideoWriter* writer); + +/** + * @brief Check if writer is opened + * @param writer Pointer to CcapVideoWriter instance + * @return true if opened, false otherwise + */ +CCAP_EXPORT bool ccap_video_writer_is_opened(const CcapVideoWriter* writer); + +/** + * @brief Write a single frame + * @param writer Pointer to CcapVideoWriter instance + * @param frameInfo Frame data to write (must match configured width/height) + * @param timestampNs Timestamp in nanoseconds (0 for auto-increment) + * @return true on success, false on failure + */ +CCAP_EXPORT bool ccap_video_writer_write_frame(CcapVideoWriter* writer, + const CcapVideoFrameInfo* frameInfo, + uint64_t timestampNs); + +/** + * @brief Get the actual codec being used (may differ from config due to fallback) + * @param writer Pointer to CcapVideoWriter instance + * @return Actual codec enum value + */ +CCAP_EXPORT CcapVideoCodec ccap_video_writer_actual_codec(const CcapVideoWriter* writer); + +#ifdef __cplusplus +} +#endif + +#endif /* CCAP_WRITER_C_H */ diff --git a/src/ccap_c.cpp b/src/ccap_c.cpp index 6e2b401c..a893e3e3 100644 --- a/src/ccap_c.cpp +++ b/src/ccap_c.cpp @@ -520,6 +520,17 @@ static_assert(static_cast(CCAP_ERROR_SEEK_FAILED) == static_cast(CCAP_ERROR_INTERNAL_ERROR) == static_cast(ccap::ErrorCode::InternalError), "C and C++ ErrorCode::InternalError values must match"); +// Video writer error code consistency checks +static_assert(static_cast(CCAP_ERROR_WRITER_OPEN_FAILED) == static_cast(ccap::ErrorCode::WriterOpenFailed), + "C and C++ ErrorCode::WriterOpenFailed values must match"); +static_assert(static_cast(CCAP_ERROR_WRITER_WRITE_FAILED) == static_cast(ccap::ErrorCode::WriterWriteFailed), + "C and C++ ErrorCode::WriterWriteFailed values must match"); +static_assert(static_cast(CCAP_ERROR_WRITER_CLOSE_FAILED) == static_cast(ccap::ErrorCode::WriterCloseFailed), + "C and C++ ErrorCode::WriterCloseFailed values must match"); +static_assert(static_cast(CCAP_ERROR_WRITER_NOT_OPENED) == static_cast(ccap::ErrorCode::WriterNotOpened), + "C and C++ ErrorCode::WriterNotOpened values must match"); +static_assert(static_cast(CCAP_ERROR_UNSUPPORTED_CODEC) == static_cast(ccap::ErrorCode::UnsupportedCodec), + "C and C++ ErrorCode::UnsupportedCodec values must match"); // LogLevel enum consistency checks static_assert(static_cast(CCAP_LOG_LEVEL_NONE) == static_cast(ccap::LogLevel::None), diff --git a/src/ccap_utils.cpp b/src/ccap_utils.cpp index 378bee86..d3b7526a 100644 --- a/src/ccap_utils.cpp +++ b/src/ccap_utils.cpp @@ -283,6 +283,16 @@ std::string_view errorCodeToString(ErrorCode errorCode) { return "Video format is not supported"; case ErrorCode::SeekFailed: return "Seek operation failed"; + case ErrorCode::WriterOpenFailed: + return "Failed to open video writer"; + case ErrorCode::WriterWriteFailed: + return "Failed to write frame"; + case ErrorCode::WriterCloseFailed: + return "Failed to finalize file"; + case ErrorCode::WriterNotOpened: + return "Writer not opened"; + case ErrorCode::UnsupportedCodec: + return "Codec not supported on this platform"; case ErrorCode::InternalError: return "Unknown or internal error"; default: diff --git a/src/ccap_writer.mm b/src/ccap_writer.mm new file mode 100644 index 00000000..50cc5a30 --- /dev/null +++ b/src/ccap_writer.mm @@ -0,0 +1,121 @@ +/** + * @file ccap_writer.mm + * @author wysaid (this@wysaid.org) + * @brief Video writer platform dispatch layer. + * @date 2025-05 + */ + +#include "ccap_writer.h" +#include "ccap_writer_imp.h" +#include "ccap_imp.h" + +#include "ccap_convert.h" +#include "ccap_utils.h" + +#include +#include +#include + +#ifdef CCAP_ENABLE_VIDEO_WRITER + +#if __APPLE__ +#include "ccap_writer_apple.mm" +#elif defined(_WIN32) || defined(_MSC_VER) +#include "ccap_writer_windows.cpp" +#endif + +namespace ccap { + +// ---- Platform dispatch ---- + +static VideoWriter::Impl* impl(void* p) { return reinterpret_cast(p); } +static const VideoWriter::Impl* impl(const void* p) { return reinterpret_cast(p); } + +VideoWriter::VideoWriter() : m_impl(nullptr) { +#if __APPLE__ + m_impl = new WriterApple(); +#elif defined(_WIN32) || defined(_MSC_VER) + m_impl = new WriterWindows(); +#endif +} + +VideoWriter::~VideoWriter() { + delete impl(m_impl); +} + +VideoWriter::VideoWriter(VideoWriter&& other) noexcept : m_impl(other.m_impl) { + other.m_impl = nullptr; +} + +VideoWriter& VideoWriter::operator=(VideoWriter&& other) noexcept { + if (this != &other) { + delete impl(m_impl); + m_impl = other.m_impl; + other.m_impl = nullptr; + } + return *this; +} + +bool VideoWriter::open(std::string_view filePath, const WriterConfig& config) { + if (!m_impl) { + reportError(ErrorCode::WriterNotOpened, "VideoWriter not available on this platform"); + return false; + } + return impl(m_impl)->open(filePath, config); +} + +void VideoWriter::close() { + if (m_impl) impl(m_impl)->close(); +} + +bool VideoWriter::isOpened() const { + return m_impl && impl(m_impl)->isOpened(); +} + +bool VideoWriter::writeFrame(const VideoFrame& frame, uint64_t timestampNs) { + if (!m_impl) return false; + return impl(m_impl)->writeFrame(frame, timestampNs); +} + +VideoCodec VideoWriter::actualCodec() const { + return m_impl ? impl(m_impl)->m_actualCodec : VideoCodec::H264; +} + +uint32_t VideoWriter::width() const { + return m_impl ? impl(m_impl)->m_config.width : 0; +} + +uint32_t VideoWriter::height() const { + return m_impl ? impl(m_impl)->m_config.height : 0; +} + +double VideoWriter::frameRate() const { + return m_impl ? impl(m_impl)->m_config.frameRate : 0.0; +} + +} // namespace ccap + +#else // CCAP_ENABLE_VIDEO_WRITER not defined + +namespace ccap { + +VideoWriter::VideoWriter() : m_impl(nullptr) {} +VideoWriter::~VideoWriter() = default; +VideoWriter::VideoWriter(VideoWriter&&) noexcept = default; +VideoWriter& VideoWriter::operator=(VideoWriter&&) noexcept = default; + +bool VideoWriter::open(std::string_view, const WriterConfig&) { + reportError(ErrorCode::WriterNotOpened, "Video writer not enabled in this build"); + return false; +} +void VideoWriter::close() {} +bool VideoWriter::isOpened() const { return false; } +bool VideoWriter::writeFrame(const VideoFrame&, uint64_t) { return false; } +VideoCodec VideoWriter::actualCodec() const { return VideoCodec::H264; } +uint32_t VideoWriter::width() const { return 0; } +uint32_t VideoWriter::height() const { return 0; } +double VideoWriter::frameRate() const { return 0.0; } + +} // namespace ccap + +#endif // CCAP_ENABLE_VIDEO_WRITER diff --git a/src/ccap_writer_apple.mm b/src/ccap_writer_apple.mm new file mode 100644 index 00000000..87bac3db --- /dev/null +++ b/src/ccap_writer_apple.mm @@ -0,0 +1,372 @@ +/** + * @file ccap_writer_apple.mm + * @author wysaid (this@wysaid.org) + * @brief Video writer implementation for macOS using AVAssetWriter. + * @date 2025-05 + */ + +#include "ccap_writer_imp.h" +#include "ccap_utils.h" + +#if __APPLE__ + +#import +#import +#import + +#include +#include +#include + +namespace ccap { + +class WriterApple : public VideoWriter::Impl { +public: + WriterApple() : m_assetWriter(nullptr), m_writerInput(nullptr), + m_pixelBufferAdaptor(nullptr), m_sessionStarted(false) {} + + ~WriterApple() override { + close(); + } + + bool open(std::string_view filePath, const WriterConfig& config) override { + if (config.width == 0 || config.height == 0) { + CCAP_LOG_E("Invalid dimensions: %ux%u\n", config.width, config.height); + return false; + } + m_config = config; + + NSString* pathStr = [NSString stringWithUTF8String: std::string(filePath).c_str()]; + + // Determine output file type + AVFileType fileType = AVFileTypeMPEG4; // MP4 + if (config.container == VideoFormat::MOV) { + fileType = AVFileTypeQuickTimeMovie; + } + + // Try HEVC first, fallback to H.264 + AVVideoCodecType codecs[] = { AVVideoCodecTypeHEVC, AVVideoCodecTypeH264 }; + VideoCodec cppCodecs[] = { VideoCodec::HEVC, VideoCodec::H264 }; + + for (int i = 0; i < 2; i++) { + if (tryOpen(filePath, fileType, pathStr, codecs[i])) { + m_actualCodec = cppCodecs[i]; + return true; + } + } + + CCAP_LOG_E("Failed to create video writer with HEVC or H.264\n"); + return false; + } + +private: + bool tryOpen(std::string_view, AVFileType fileType, NSString* pathStr, AVVideoCodecType codec) { + NSURL* url = [NSURL fileURLWithPath: pathStr]; + NSError* error = nil; + int64_t bitRate = (m_config.bitRate > 0) ? static_cast(m_config.bitRate) : static_cast(m_config.width) * m_config.height * 4; + int frameRateInt = (m_config.frameRate > 0) ? static_cast(m_config.frameRate) : 30; + int maxKeyFrameInterval = frameRateInt * 2; + + NSDictionary* videoSettings = @{ + AVVideoCodecKey: codec, + AVVideoWidthKey: @(m_config.width), + AVVideoHeightKey: @(m_config.height), + AVVideoCompressionPropertiesKey: @{ + AVVideoAverageBitRateKey: @(bitRate), + AVVideoExpectedSourceFrameRateKey: @(frameRateInt), + AVVideoMaxKeyFrameIntervalKey: @(maxKeyFrameInterval), + }, + }; + + // Delete existing file + [[NSFileManager defaultManager] removeItemAtPath: pathStr error: nil]; + + @try { + m_assetWriter = [[AVAssetWriter alloc] initWithURL: url fileType: fileType error: &error]; + if (error) { + CCAP_LOG_E("AVAssetWriter creation failed: %s\n", error.localizedDescription.UTF8String); + m_assetWriter = nil; + return false; + } + + m_writerInput = [AVAssetWriterInput assetWriterInputWithMediaType: AVMediaTypeVideo + outputSettings: videoSettings]; + if (!m_writerInput) { + CCAP_LOG_E("AVAssetWriterInput creation failed\n"); + [m_assetWriter cancelWriting]; + m_assetWriter = nil; + return false; + } + + m_writerInput.expectsMediaDataInRealTime = NO; + [m_assetWriter addInput: m_writerInput]; + + // CRITICAL: Pixel buffer adaptor MUST be created before startWriting + NSDictionary* pixelBufferAttrs = @{ + (id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange), + (id)kCVPixelBufferWidthKey: @(m_config.width), + (id)kCVPixelBufferHeightKey: @(m_config.height), + }; + + m_pixelBufferAdaptor = [AVAssetWriterInputPixelBufferAdaptor + assetWriterInputPixelBufferAdaptorWithAssetWriterInput: m_writerInput + sourcePixelBufferAttributes: pixelBufferAttrs]; + if (!m_pixelBufferAdaptor) { + CCAP_LOG_E("Pixel buffer adaptor creation failed\n"); + [m_assetWriter cancelWriting]; + m_assetWriter = nil; + m_writerInput = nil; + return false; + } + + if (![m_assetWriter startWriting]) { + CCAP_LOG_E("startWriting failed: %s\n", m_assetWriter.error.localizedDescription.UTF8String); + [m_assetWriter cancelWriting]; + m_assetWriter = nil; + m_writerInput = nil; + m_pixelBufferAdaptor = nil; + return false; + } + + // Start session at time 0 so we can append samples immediately + [m_assetWriter startSessionAtSourceTime: CMTimeMake(0, 1)]; + m_sessionStarted = YES; + m_frameCount = 0; + m_isOpened = true; + return true; + } + @catch (NSException* e) { + CCAP_LOG_E("Exception during writer setup: %s\n", e.reason.UTF8String); + m_assetWriter = nil; + m_writerInput = nil; + m_pixelBufferAdaptor = nil; + return false; + } + } + +public: + + void close() override { + if (!m_isOpened) return; + m_isOpened = false; + + @try { + if (m_writerInput) { + [m_writerInput markAsFinished]; + } + if (m_assetWriter) { + // Use a background queue to avoid blocking the calling thread + // which allows the completion handler to execute + dispatch_queue_t queue = dispatch_queue_create("com.ccap.writer.close", DISPATCH_QUEUE_SERIAL); + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + dispatch_async(queue, ^{ + [m_assetWriter finishWritingWithCompletionHandler:^{ + dispatch_semaphore_signal(sem); + }]; + }); + // Wait for completion + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + + if (m_assetWriter.error) { + CCAP_LOG_E("finishWriting failed: %s\n", + m_assetWriter.error.localizedDescription.UTF8String); + } + } + } + @catch (NSException* e) { + CCAP_LOG_E("Exception during writer close: %s\n", e.reason.UTF8String); + } + + m_pixelBufferAdaptor = nil; + m_writerInput = nil; + m_assetWriter = nil; + std::memset(&m_config, 0, sizeof(m_config)); + } + + bool isOpened() const override { + return m_isOpened; + } + + bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) override { + if (!m_isOpened || !m_writerInput || !m_assetWriter || !m_pixelBufferAdaptor) return false; + + @try { + // Wait for writer input to be ready (with 2 second timeout) + int waitMs = 0; + while (![m_writerInput isReadyForMoreMediaData]) { + usleep(1000); // 1ms + if (++waitMs > 2000) { + CCAP_LOG_W("Writer input not ready after 2s, dropping frame\n"); + return false; + } + } + + int w = static_cast(frame.width); + int h = static_cast(frame.height); + int w2 = (w + 1) / 2; + int h2 = (h + 1) / 2; + + // Convert frame to NV12 + std::vector yBuf, uvBuf; + uint32_t yStride, uvStride; + { + yStride = static_cast(w); + uvStride = static_cast(w2 * 2); + yBuf.resize(static_cast(yStride) * h); + uvBuf.resize(static_cast(uvStride) * h2); + + uint8_t* dstYTmp = yBuf.data(); + uint8_t* dstUVTmp = uvBuf.data(); + + if (frame.pixelFormat == PixelFormat::NV12 || frame.pixelFormat == PixelFormat::NV12f) { + for (int y = 0; y < h; y++) { + memcpy(dstYTmp + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); + } + for (int y = 0; y < h2; y++) { + memcpy(dstUVTmp + y * uvStride, frame.data[1] + y * frame.stride[1], static_cast(w2) * 2); + } + } else if (frame.pixelFormat == PixelFormat::I420 || frame.pixelFormat == PixelFormat::I420f) { + for (int y = 0; y < h; y++) { + memcpy(dstYTmp + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); + } + for (int y = 0; y < h2; y++) { + for (int x = 0; x < w2; x++) { + dstUVTmp[y * uvStride + x * 2] = frame.data[1][y * frame.stride[1] + x]; + dstUVTmp[y * uvStride + x * 2 + 1] = frame.data[2][y * frame.stride[2] + x]; + } + } + } else if (frame.pixelFormat == PixelFormat::BGR24) { + bgr24ToNv12(frame.data[0], static_cast(frame.stride[0]), + dstYTmp, static_cast(yStride), + dstUVTmp, static_cast(uvStride), w, h); + } else if (frame.pixelFormat == PixelFormat::BGRA32) { + bgra32ToNv12(frame.data[0], static_cast(frame.stride[0]), + dstYTmp, static_cast(yStride), + dstUVTmp, static_cast(uvStride), w, h); + } else { + CCAP_LOG_E("Unsupported pixel format for writer on macOS: %d\n", + static_cast(frame.pixelFormat)); + return false; + } + } + + // Create CVPixelBuffer + CVPixelBufferRef pixelBuffer = nullptr; + CVReturn ret = CVPixelBufferCreate(kCFAllocatorDefault, w, h, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + nullptr, &pixelBuffer); + if (ret != kCVReturnSuccess) { + CCAP_LOG_E("CVPixelBufferCreate failed: %d\n", ret); + return false; + } + + // Fill pixel buffer with converted data + CVPixelBufferLockBaseAddress(pixelBuffer, 0); + uint8_t* dstY = static_cast(CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0)); + size_t dstYStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0); + uint8_t* dstUV = static_cast(CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1)); + size_t dstUVStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1); + + for (int y = 0; y < h; y++) { + memcpy(dstY + y * dstYStride, yBuf.data() + y * yStride, static_cast(w)); + } + for (int y = 0; y < h2; y++) { + memcpy(dstUV + y * dstUVStride, uvBuf.data() + y * uvStride, static_cast(w2) * 2); + } + + CVPixelBufferUnlockBaseAddress(pixelBuffer, 0); + + // Calculate timestamp + CMTime presentationTime; + if (timestampNs > 0) { + presentationTime = CMTimeMake(static_cast(timestampNs), 1000000000); + } else { + double fps = m_config.frameRate > 0 ? m_config.frameRate : 30.0; + presentationTime = CMTimeMake(static_cast(m_frameCount), static_cast(fps)); + } + + // Append pixel buffer via adaptor + BOOL success = [m_pixelBufferAdaptor appendPixelBuffer: pixelBuffer + withPresentationTime: presentationTime]; + CVPixelBufferRelease(pixelBuffer); + + if (!success) { + CCAP_LOG_E("appendPixelBuffer failed: %s\n", + m_assetWriter.error ? m_assetWriter.error.localizedDescription.UTF8String : "unknown"); + return false; + } + + m_frameCount++; + return true; + } + @catch (NSException* e) { + CCAP_LOG_E("Exception during writeFrame: %s\n", e.reason.UTF8String); + return false; + } + } + +private: + // Inline conversion helpers + void bgr24ToNv12(const uint8_t* src, int srcStride, + uint8_t* dstY, int dstYStride, + uint8_t* dstUV, int dstUVStride, + int width, int height) { + int w2 = width / 2; + const uint8_t* line = src; + for (int y = 0; y < height; y += 2) { + const uint8_t* line0 = line; + const uint8_t* line1 = (y + 1 < height) ? line + srcStride : line; + for (int x = 0; x < w2; x++) { + int b0 = line0[x*6+0], g0 = line0[x*6+1], r0 = line0[x*6+2]; + int b1 = line0[x*6+3], g1 = line0[x*6+4], r1 = line0[x*6+5]; + int b2 = line1[x*6+0], g2 = line1[x*6+1], r2 = line1[x*6+2]; + int b3 = line1[x*6+3], g3 = line1[x*6+4], r3 = line1[x*6+5]; + dstY[y * dstYStride + x*2] = static_cast((66*r0+129*g0+25*b0+128)>>8)+16; + dstY[y * dstYStride + x*2+1] = static_cast((66*r1+129*g1+25*b1+128)>>8)+16; + dstY[(y+1) * dstYStride + x*2] = static_cast((66*r2+129*g2+25*b2+128)>>8)+16; + dstY[(y+1) * dstYStride + x*2+1] = static_cast((66*r3+129*g3+25*b3+128)>>8)+16; + int bAvg = (b0+b1+b2+b3)/4, rAvg = (r0+r1+r2+r3)/4, gAvg = (g0+g1+g2+g3)/4; + dstUV[(y/2) * dstUVStride + x*2] = static_cast((-38*rAvg-74*gAvg+112*bAvg+128)>>8)+128; + dstUV[(y/2) * dstUVStride + x*2+1] = static_cast((112*rAvg-94*gAvg-18*bAvg+128)>>8)+128; + } + line += srcStride * 2; + } + } + + void bgra32ToNv12(const uint8_t* src, int srcStride, + uint8_t* dstY, int dstYStride, + uint8_t* dstUV, int dstUVStride, + int width, int height) { + int w2 = width / 2; + const uint8_t* line = src; + for (int y = 0; y < height; y += 2) { + const uint8_t* line0 = line; + const uint8_t* line1 = (y + 1 < height) ? line + srcStride : line; + for (int x = 0; x < w2; x++) { + int b0 = line0[x*8+0], g0 = line0[x*8+1], r0 = line0[x*8+2]; + int b1 = line0[x*8+4], g1 = line0[x*8+5], r1 = line0[x*8+6]; + int b2 = line1[x*8+0], g2 = line1[x*8+1], r2 = line1[x*8+2]; + int b3 = line1[x*8+4], g3 = line1[x*8+5], r3 = line1[x*8+6]; + dstY[y * dstYStride + x*2] = static_cast((66*r0+129*g0+25*b0+128)>>8)+16; + dstY[y * dstYStride + x*2+1] = static_cast((66*r1+129*g1+25*b1+128)>>8)+16; + dstY[(y+1) * dstYStride + x*2] = static_cast((66*r2+129*g2+25*b2+128)>>8)+16; + dstY[(y+1) * dstYStride + x*2+1] = static_cast((66*r3+129*g3+25*b3+128)>>8)+16; + int bAvg = (b0+b1+b2+b3)/4, rAvg = (r0+r1+r2+r3)/4, gAvg = (g0+g1+g2+g3)/4; + dstUV[(y/2) * dstUVStride + x*2] = static_cast((-38*rAvg-74*gAvg+112*bAvg+128)>>8)+128; + dstUV[(y/2) * dstUVStride + x*2+1] = static_cast((112*rAvg-94*gAvg-18*bAvg+128)>>8)+128; + } + line += srcStride * 2; + } + } + + AVAssetWriter* m_assetWriter; + AVAssetWriterInput* m_writerInput; + AVAssetWriterInputPixelBufferAdaptor* m_pixelBufferAdaptor; + BOOL m_sessionStarted; + std::atomic m_isOpened{false}; + std::atomic m_frameCount{0}; +}; + +} // namespace ccap + +#endif // __APPLE__ diff --git a/src/ccap_writer_c.cpp b/src/ccap_writer_c.cpp new file mode 100644 index 00000000..8149f51f --- /dev/null +++ b/src/ccap_writer_c.cpp @@ -0,0 +1,117 @@ +/** + * @file ccap_writer_c.cpp + * @author wysaid (this@wysaid.org) + * @brief Pure C interface implementation for ccap video writer. + * @date 2025-05 + */ + +#include "ccap_writer_c.h" + +#ifdef CCAP_ENABLE_VIDEO_WRITER + +#include "ccap_writer.h" + +extern "C" { + +CcapVideoWriter* ccap_video_writer_create(void) { + try { + return reinterpret_cast(new ccap::VideoWriter()); + } catch (...) { + return nullptr; + } +} + +void ccap_video_writer_destroy(CcapVideoWriter* writer) { + if (writer) { + delete reinterpret_cast(writer); + } +} + +bool ccap_video_writer_open(CcapVideoWriter* writer, const char* filePath, + const CcapWriterConfig* config) { + if (!writer || !filePath || !config) return false; + + try { + auto* cppWriter = reinterpret_cast(writer); + + ccap::WriterConfig cppConfig; + cppConfig.codec = (config->codec == CCAP_VIDEO_CODEC_HEVC) + ? ccap::VideoCodec::HEVC : ccap::VideoCodec::H264; + cppConfig.container = (config->container == CCAP_VIDEO_FORMAT_MOV) + ? ccap::VideoFormat::MOV : ccap::VideoFormat::MP4; + cppConfig.width = config->width; + cppConfig.height = config->height; + cppConfig.frameRate = config->frameRate; + cppConfig.bitRate = config->bitRate; + + return cppWriter->open(filePath, cppConfig); + } catch (...) { + return false; + } +} + +void ccap_video_writer_close(CcapVideoWriter* writer) { + if (writer) { + reinterpret_cast(writer)->close(); + } +} + +bool ccap_video_writer_is_opened(const CcapVideoWriter* writer) { + if (!writer) return false; + return reinterpret_cast(writer)->isOpened(); +} + +bool ccap_video_writer_write_frame(CcapVideoWriter* writer, + const CcapVideoFrameInfo* frameInfo, + uint64_t timestampNs) { + if (!writer || !frameInfo) return false; + + try { + auto* cppWriter = reinterpret_cast(writer); + + // Build a temporary VideoFrame wrapper + // Note: CcapVideoFrame is actually a shared_ptr + // But for write_frame we construct a minimal VideoFrame on the stack + ccap::VideoFrame frame; + for (int i = 0; i < 3; i++) { + frame.data[i] = frameInfo->data[i]; + frame.stride[i] = frameInfo->stride[i]; + } + frame.pixelFormat = static_cast(static_cast(frameInfo->pixelFormat)); + frame.width = frameInfo->width; + frame.height = frameInfo->height; + frame.sizeInBytes = frameInfo->sizeInBytes; + frame.timestamp = timestampNs > 0 ? timestampNs : frameInfo->timestamp; + frame.frameIndex = frameInfo->frameIndex; + frame.orientation = static_cast(static_cast(frameInfo->orientation)); + + return cppWriter->writeFrame(frame, timestampNs); + } catch (...) { + return false; + } +} + +CcapVideoCodec ccap_video_writer_actual_codec(const CcapVideoWriter* writer) { + if (!writer) return CCAP_VIDEO_CODEC_H264; + auto* cppWriter = reinterpret_cast(writer); + return (cppWriter->actualCodec() == ccap::VideoCodec::HEVC) + ? CCAP_VIDEO_CODEC_HEVC : CCAP_VIDEO_CODEC_H264; +} + +} // extern "C" + +#else // CCAP_ENABLE_VIDEO_WRITER not defined + +extern "C" { + +CcapVideoWriter* ccap_video_writer_create(void) { return nullptr; } +void ccap_video_writer_destroy(CcapVideoWriter*) {} +bool ccap_video_writer_open(CcapVideoWriter*, const char*, const CcapWriterConfig*) { return false; } +void ccap_video_writer_close(CcapVideoWriter*) {} +bool ccap_video_writer_is_opened(const CcapVideoWriter*) { return false; } +bool ccap_video_writer_write_frame(CcapVideoWriter*, const CcapVideoFrameInfo*, uint64_t) { return false; } +CcapVideoCodec ccap_video_writer_actual_codec(const CcapVideoWriter*) { return CCAP_VIDEO_CODEC_H264; } + +} // extern "C" + +#endif // CCAP_ENABLE_VIDEO_WRITER diff --git a/src/ccap_writer_imp.h b/src/ccap_writer_imp.h new file mode 100644 index 00000000..5b58eebc --- /dev/null +++ b/src/ccap_writer_imp.h @@ -0,0 +1,33 @@ +/** + * @file ccap_writer_imp.h + * @brief Internal header for VideoWriter platform implementations. + */ + +#pragma once + +#ifndef CCAP_WRITER_IMP_H +#define CCAP_WRITER_IMP_H + +#include "ccap_writer.h" +#include "ccap_def.h" + +#include + +namespace ccap { + +struct VideoWriter::Impl { + Impl() : m_actualCodec(VideoCodec::H264) {} + virtual ~Impl() = default; + + virtual bool open(std::string_view filePath, const WriterConfig& config) = 0; + virtual void close() = 0; + virtual bool isOpened() const = 0; + virtual bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) = 0; + + VideoCodec m_actualCodec; + WriterConfig m_config; +}; + +} // namespace ccap + +#endif // CCAP_WRITER_IMP_H diff --git a/src/ccap_writer_windows.cpp b/src/ccap_writer_windows.cpp new file mode 100644 index 00000000..0e9a3962 --- /dev/null +++ b/src/ccap_writer_windows.cpp @@ -0,0 +1,338 @@ +/** + * @file ccap_writer_windows.cpp + * @author wysaid (this@wysaid.org) + * @brief Video writer implementation for Windows using Media Foundation Sink Writer. + * @date 2025-05 + */ + +#include "ccap_writer_imp.h" +#include "ccap_utils.h" + +#if defined(_WIN32) || defined(_MSC_VER) + +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#pragma comment(lib, "mf.lib") +#pragma comment(lib, "mfplat.lib") +#pragma comment(lib, "mfreadwrite.lib") +#pragma comment(lib, "mfuuid.lib") + +namespace ccap { + +class WriterWindows : public VideoWriter::Impl { +public: + WriterWindows() : m_sinkWriter(nullptr), m_streamIndex(0), m_mfInitialized(false) { + HRESULT hr = MFStartup(MF_VERSION, MFSTARTUP_FULL); + m_mfInitialized = SUCCEEDED(hr); + } + + ~WriterWindows() override { + close(); + if (m_mfInitialized) { + MFShutdown(); + } + } + + bool open(std::string_view filePath, const WriterConfig& config) override { + m_config = config; + + // Convert path to wide string + std::wstring widePath(filePath.begin(), filePath.end()); + + // Try HEVC first, fallback to H.264 + GUID videoCodec = MFVideoFormat_HEVC; + m_actualCodec = VideoCodec::HEVC; + if (!tryCreateWriter(widePath, videoCodec, config)) { + videoCodec = MFVideoFormat_H264; + m_actualCodec = VideoCodec::H264; + if (!tryCreateWriter(widePath, videoCodec, config)) { + CCAP_LOG_E("Failed to create video writer with H.264 or HEVC\n"); + return false; + } + } + + m_frameCount = 0; + m_isOpened = true; + return true; + } + + void close() override { + if (!m_isOpened) return; + m_isOpened = false; + + if (m_sinkWriter) { + m_sinkWriter->Finalize(); + m_sinkWriter->Release(); + m_sinkWriter = nullptr; + } + } + + bool isOpened() const override { + return m_isOpened; + } + + bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) override { + if (!m_isOpened || !m_sinkWriter) return false; + + int w = static_cast(frame.width); + int h = static_cast(frame.height); + int w2 = (w + 1) / 2; + int h2 = (h + 1) / 2; + + // Convert frame to NV12 + std::vector yBuf, uvBuf; + uint32_t yStride, uvStride; + if (!convertToNv12(frame, yBuf, uvBuf, yStride, uvStride)) { + return false; + } + + // Create total buffer size: Y + UV + int totalSize = static_cast(yStride) * h + static_cast(uvStride) * h2; + + // Create sample + IMFSample* pSample = nullptr; + HRESULT hr = MFCreateSample(&pSample); + if (FAILED(hr)) { + CCAP_LOG_E("MFCreateSample failed: 0x%08X\n", hr); + return false; + } + + IMFSinkWriter* pWriter = m_sinkWriter; + + // Add buffer + IMFMediaBuffer* pBuffer = nullptr; + hr = MFCreateMemoryBuffer(static_cast(totalSize), &pBuffer); + if (FAILED(hr)) { + CCAP_LOG_E("MFCreateMemoryBuffer failed: 0x%08X\n", hr); + pSample->Release(); + return false; + } + + BYTE* pData = nullptr; + hr = pBuffer->Lock(&pData, nullptr, nullptr); + if (FAILED(hr)) { + CCAP_LOG_E("Buffer Lock failed: 0x%08X\n", hr); + pBuffer->Release(); + pSample->Release(); + return false; + } + + // Copy Y plane + for (int y = 0; y < h; y++) { + memcpy(pData + y * yStride, yBuf.data() + y * yStride, static_cast(w)); + } + // Copy UV plane + uint8_t* uvStart = pData + static_cast(yStride) * h; + for (int y = 0; y < h2; y++) { + memcpy(uvStart + y * uvStride, uvBuf.data() + y * uvStride, static_cast(w2) * 2); + } + + pBuffer->Unlock(); + pBuffer->SetCurrentLength(static_cast(totalSize)); + pSample->AddBuffer(pBuffer); + pBuffer->Release(); + + // Set timestamp + LONGLONG hnsTimestamp; + if (timestampNs > 0) { + hnsTimestamp = static_cast(timestampNs / 100); // ns to 100ns + } else { + double fps = m_config.frameRate > 0 ? m_config.frameRate : 30.0; + hnsTimestamp = static_cast(m_frameCount * 10000000.0 / fps); + } + pSample->SetSampleTime(hnsTimestamp); + + // Write sample + hr = pWriter->WriteSample(m_streamIndex, pSample); + pSample->Release(); + + if (FAILED(hr)) { + CCAP_LOG_E("WriteSample failed: 0x%08X\n", hr); + return false; + } + + m_frameCount++; + return true; + } + +private: + bool tryCreateWriter(const std::wstring& filePath, GUID videoCodec, const WriterConfig& config) { + IMFAttributes* pAttributes = nullptr; + HRESULT hr = MFCreateAttributes(&pAttributes, 1); + if (FAILED(hr)) return false; + + hr = pAttributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); + if (FAILED(hr)) { + pAttributes->Release(); + return false; + } + + IMFAttributes* pEncodingAttributes = nullptr; + if (videoCodec == MFVideoFormat_H264) { + hr = MFCreateAttributes(&pEncodingAttributes, 2); + if (SUCCEEDED(hr)) { + hr = pEncodingAttributes->SetUINT32(MF_LOW_LATENCY, TRUE); + } + } + + IMFMediaSink* pSink = nullptr; + hr = MFCreateMediaSinkForURL(filePath.c_str(), pAttributes, &pSink); + if (FAILED(hr)) { + CCAP_LOG_E("MFCreateMediaSinkForURL failed: 0x%08X\n", hr); + pAttributes->Release(); + if (pEncodingAttributes) pEncodingAttributes->Release(); + return false; + } + + IMFMediaType* pOutputType = nullptr; + hr = MFCreateMediaType(&pOutputType); + if (FAILED(hr)) { + pSink->Release(); + pAttributes->Release(); + if (pEncodingAttributes) pEncodingAttributes->Release(); + return false; + } + + pOutputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + pOutputType->SetGUID(MF_MT_SUBTYPE, videoCodec); + pOutputType->SetUINT32(MF_MT_AVG_BITRATE, static_cast(config.bitRate > 0 ? config.bitRate : config.width * config.height * 4)); + pOutputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + + // Set frame rate + UINT32 fpsNum = static_cast(config.frameRate * 1000); + UINT32 fpsDen = 1000; + if (config.frameRate <= 0) { fpsNum = 30000; fpsDen = 1000; } + MFSetAttributeRatio(pOutputType, MF_MT_FRAME_RATE, fpsNum, fpsDen); + + MFSetAttributeSize(pOutputType, MF_MT_FRAME_SIZE, config.width, config.height); + + hr = pSink->AddStream(pOutputType, &m_streamIndex); + pOutputType->Release(); + if (FAILED(hr)) { + CCAP_LOG_E("AddStream failed: 0x%08X\n", hr); + pSink->Release(); + pAttributes->Release(); + if (pEncodingAttributes) pEncodingAttributes->Release(); + return false; + } + + hr = MFCreateSinkWriterFromMediaSink(pSink, pEncodingAttributes, &m_sinkWriter); + pSink->Release(); + pAttributes->Release(); + if (pEncodingAttributes) pEncodingAttributes->Release(); + + if (FAILED(hr)) { + CCAP_LOG_E("MFCreateSinkWriterFromMediaSink failed: 0x%08X\n", hr); + return false; + } + + // Set input type (NV12) + IMFMediaType* pInputType = nullptr; + hr = MFCreateMediaType(&pInputType); + if (FAILED(hr)) { + m_sinkWriter->Release(); + m_sinkWriter = nullptr; + return false; + } + + pInputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + pInputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12); + pInputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + MFSetAttributeSize(pInputType, MF_MT_FRAME_SIZE, config.width, config.height); + MFSetAttributeRatio(pInputType, MF_MT_FRAME_RATE, fpsNum, fpsDen); + + hr = m_sinkWriter->SetInputMediaType(m_streamIndex, pInputType, nullptr); + pInputType->Release(); + + if (FAILED(hr)) { + CCAP_LOG_E("SetInputMediaType failed: 0x%08X\n", hr); + m_sinkWriter->Release(); + m_sinkWriter = nullptr; + return false; + } + + // Start writing + hr = m_sinkWriter->BeginWriting(); + if (FAILED(hr)) { + CCAP_LOG_E("BeginWriting failed: 0x%08X\n", hr); + m_sinkWriter->Release(); + m_sinkWriter = nullptr; + return false; + } + + return true; + } + + bool convertToNv12(const VideoFrame& frame, + std::vector& yBuf, std::vector& uvBuf, + uint32_t& yStride, uint32_t& uvStride) { + int w = static_cast(frame.width); + int h = static_cast(frame.height); + int w2 = (w + 1) / 2; + int h2 = (h + 1) / 2; + + yStride = static_cast(w); + uvStride = static_cast(w2 * 2); + + yBuf.resize(static_cast(yStride) * h); + uvBuf.resize(static_cast(uvStride) * h2); + + if (frame.pixelFormat == PixelFormat::BGR24) { + const uint8_t* src = frame.data[0]; + int srcStride = static_cast(frame.stride[0]); + uint8_t* dstY = yBuf.data(); + uint8_t* dstUV = uvBuf.data(); + for (int y = 0; y < h; y += 2) { + const uint8_t* l0 = src + y * srcStride; + const uint8_t* l1 = src + (y + 1 < h ? (y + 1) * srcStride : y * srcStride); + for (int x = 0; x < w2; x++) { + int b0 = l0[x*6+0], g0 = l0[x*6+1], r0 = l0[x*6+2]; + int b1 = l0[x*6+3], g1 = l0[x*6+4], r1 = l0[x*6+5]; + int b2 = l1[x*6+0], g2 = l1[x*6+1], r2 = l1[x*6+2]; + int b3 = l1[x*6+3], g3 = l1[x*6+4], r3 = l1[x*6+5]; + dstY[y*yStride + x*2] = static_cast((66*r0+129*g0+25*b0+128)>>8)+16; + dstY[y*yStride + x*2+1] = static_cast((66*r1+129*g1+25*b1+128)>>8)+16; + dstY[(y+1)*yStride + x*2] = static_cast((66*r2+129*g2+25*b2+128)>>8)+16; + dstY[(y+1)*yStride + x*2+1] = static_cast((66*r3+129*g3+25*b3+128)>>8)+16; + int bA=(b0+b1+b2+b3)/4, rA=(r0+r1+r2+r3)/4, gA=(g0+g1+g2+g3)/4; + dstUV[(y/2)*uvStride + x*2] = static_cast((-38*rA-74*gA+112*bA+128)>>8)+128; + dstUV[(y/2)*uvStride + x*2+1] = static_cast((112*rA-94*gA-18*bA+128)>>8)+128; + } + } + } else if (frame.pixelFormat == PixelFormat::NV12 || frame.pixelFormat == PixelFormat::NV12f) { + for (int y = 0; y < h; y++) { + memcpy(yBuf.data() + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); + } + for (int y = 0; y < h2; y++) { + memcpy(uvBuf.data() + y * uvStride, frame.data[1] + y * frame.stride[1], static_cast(w2) * 2); + } + } else { + CCAP_LOG_E("Unsupported pixel format for writer on Windows: %d\n", + static_cast(frame.pixelFormat)); + return false; + } + return true; + } + + IMFSinkWriter* m_sinkWriter; + DWORD m_streamIndex; + bool m_mfInitialized; + std::atomic m_isOpened{false}; + std::atomic m_frameCount{0}; +}; + +} // namespace ccap + +#endif // _WIN32 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d8242932..3b2b9bb7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -435,6 +435,55 @@ else () message(STATUS "ccap: File playback tests disabled (CCAP_ENABLE_FILE_PLAYBACK is OFF)") endif () +# Video writer test executable - tests video file writing functionality +# Only build on Windows/macOS with video writer enabled +if (CCAP_ENABLE_VIDEO_WRITER AND (APPLE OR WIN32)) + add_executable( + ccap_video_writer_test + test_video_writer.cpp + ) + + target_link_libraries( + ccap_video_writer_test + PRIVATE + ccap_test_utils + gtest + gmock + ) + + set_target_properties(ccap_video_writer_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + ) + + target_compile_definitions(ccap_video_writer_test PRIVATE + $<$:DEBUG> + $<$:NDEBUG> + $<$>,$>>:GTEST_HAS_PTHREAD=1> + ) + + if (MSVC) + target_compile_options(ccap_video_writer_test PRIVATE + /MP + /std:c++17 + /Zc:__cplusplus + /Zc:preprocessor + /source-charset:utf-8 + /bigobj + /wd4996 + /D_CRT_SECURE_NO_WARNINGS + ) + else () + target_compile_options(ccap_video_writer_test PRIVATE + -std=c++17 + ) + endif () + + message(STATUS "ccap: Video writer tests enabled") +else () + message(STATUS "ccap: Video writer tests disabled") +endif () + # Enable testing before any test registration enable_testing() @@ -460,6 +509,9 @@ if (NOT CMAKE_CROSSCOMPILING) gtest_discover_tests(ccap_file_playback_test DISCOVERY_MODE PRE_TEST) gtest_discover_tests(ccap_memory_safety_test DISCOVERY_MODE PRE_TEST) endif () + if (CCAP_ENABLE_VIDEO_WRITER AND (APPLE OR WIN32)) + gtest_discover_tests(ccap_video_writer_test DISCOVERY_MODE PRE_TEST) + endif () else () message(STATUS "CMAKE_CROSSCOMPILING is ON: skipping GoogleTest discovery at configure time.") endif () diff --git a/tests/test_video_writer.cpp b/tests/test_video_writer.cpp new file mode 100644 index 00000000..bd86c88a --- /dev/null +++ b/tests/test_video_writer.cpp @@ -0,0 +1,414 @@ +/** + * @file test_video_writer.cpp + * @brief Tests for video writer functionality + * + * Tests verify basic writer lifecycle, frame writing, and output validation. + * Output files are written to a temporary directory and cleaned up after. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +// Helper to check if video writer is supported on this platform +bool isVideoWriterSupported() { +#if (defined(__APPLE__) || defined(_WIN32)) && defined(CCAP_ENABLE_VIDEO_WRITER) + return true; +#else + return false; +#endif +} + +// Generate a unique temp path for test output +fs::path getTestOutputPath(const std::string& name) { + return fs::temp_directory_path() / ("ccap_writer_test_" + name + ".mp4"); +} + +// Create a synthetic BGR24 frame with random noise +std::vector createBgrFrame(int w, int h, int stride) { + std::vector data(static_cast(stride) * h); + std::mt19937 gen(42); // fixed seed for reproducibility + std::uniform_int_distribution<> dist(0, 255); + for (size_t i = 0; i < data.size(); i++) { + data[i] = static_cast(dist(gen)); + } + return data; +} + +// Test fixture for video writer tests +class VideoWriterTest : public ::testing::Test { +protected: + void SetUp() override { + if (!isVideoWriterSupported()) { + GTEST_SKIP() << "Video writer not supported on this platform/build"; + } + } + + void TearDown() override { + // Clean up any test output files + for (const auto& entry : fs::directory_iterator(fs::temp_directory_path())) { + std::string filename = entry.path().filename().string(); + if (filename.find("ccap_writer_test_") == 0) { + fs::remove(entry.path()); + } + } + } +}; + +// Test fixture for C API tests +class VideoWriterCTest : public ::testing::Test { +protected: + void SetUp() override { + if (!isVideoWriterSupported()) { + GTEST_SKIP() << "Video writer not supported on this platform/build"; + } + } + + void TearDown() override { + for (const auto& entry : fs::directory_iterator(fs::temp_directory_path())) { + std::string filename = entry.path().filename().string(); + if (filename.find("ccap_writer_test_") == 0) { + fs::remove(entry.path()); + } + } + } +}; + +// ---- C++ API Tests ---- + +TEST_F(VideoWriterTest, ConstructAndDestroy) { + ccap::VideoWriter writer; + EXPECT_FALSE(writer.isOpened()); +} + +TEST_F(VideoWriterTest, MoveConstructor) { + ccap::VideoWriter writer1; + ccap::VideoWriter writer2(std::move(writer1)); + EXPECT_FALSE(writer2.isOpened()); +} + +TEST_F(VideoWriterTest, MoveAssignment) { + ccap::VideoWriter writer1; + ccap::VideoWriter writer2; + writer2 = std::move(writer1); + EXPECT_FALSE(writer2.isOpened()); +} + +TEST_F(VideoWriterTest, OpenInvalidPath) { + ccap::VideoWriter writer; + ccap::WriterConfig config; + config.width = 640; + config.height = 480; + config.frameRate = 30.0; + config.bitRate = 5000000; + + // Invalid path should fail + bool result = writer.open("/nonexistent/deeply/nested/path/output.mp4", config); + EXPECT_FALSE(result); + EXPECT_FALSE(writer.isOpened()); +} + +TEST_F(VideoWriterTest, OpenZeroDimensions) { + ccap::VideoWriter writer; + ccap::WriterConfig config; + config.width = 0; + config.height = 0; + config.frameRate = 30.0; + config.bitRate = 5000000; + + bool result = writer.open(getTestOutputPath("zero_dim").string(), config); + EXPECT_FALSE(result); +} + +TEST_F(VideoWriterTest, OpenAndClose) { + ccap::VideoWriter writer; + ccap::WriterConfig config; + config.width = 640; + config.height = 480; + config.frameRate = 30.0; + config.bitRate = 5000000; + + fs::path outputPath = getTestOutputPath("open_close"); + bool result = writer.open(outputPath.string(), config); + EXPECT_TRUE(result); + EXPECT_TRUE(writer.isOpened()); + + writer.close(); + EXPECT_FALSE(writer.isOpened()); + + // Verify output file was created (may be empty since no frames were written) + EXPECT_TRUE(fs::exists(outputPath)); +} + +TEST_F(VideoWriterTest, WriteFramesAndValidateFile) { + ccap::VideoWriter writer; + ccap::WriterConfig config; + config.width = 320; + config.height = 240; + config.frameRate = 30.0; + config.bitRate = 2000000; + + fs::path outputPath = getTestOutputPath("write_frames"); + ASSERT_TRUE(writer.open(outputPath.string(), config)); + + // Create and write 30 frames (1 second at 30fps) + int w = 320, h = 240; + int stride = w * 3; // BGR24 + std::vector frameData = createBgrFrame(w, h, stride); + + ccap::VideoFrame frame; + frame.data[0] = frameData.data(); + frame.stride[0] = static_cast(stride); + frame.data[1] = nullptr; + frame.stride[1] = 0; + frame.data[2] = nullptr; + frame.stride[2] = 0; + frame.pixelFormat = ccap::PixelFormat::BGR24; + frame.width = static_cast(w); + frame.height = static_cast(h); + frame.sizeInBytes = static_cast(stride * h); + frame.timestamp = 0; + frame.frameIndex = 0; + frame.orientation = ccap::FrameOrientation::Default; + + for (int i = 0; i < 30; i++) { + frame.timestamp = static_cast(i) * 33333333; // ~30fps in ns + frame.frameIndex = static_cast(i); + bool writeResult = writer.writeFrame(frame); + EXPECT_TRUE(writeResult); + } + + writer.close(); + + // Verify file exists and has reasonable size + EXPECT_TRUE(fs::exists(outputPath)); + uint64_t fileSize = fs::file_size(outputPath); + // 30 frames at 320x240 with 2Mbps bitrate should produce at least a few KB + EXPECT_GT(fileSize, 1000); + EXPECT_LT(fileSize, 50 * 1024 * 1024); // less than 50MB + + // Verify file can be opened for playback + ccap::Provider provider; + EXPECT_TRUE(provider.open(outputPath.string())); + auto framePtr = provider.grab(5000); + EXPECT_NE(framePtr, nullptr); + if (framePtr) { + EXPECT_EQ(framePtr->width, 320); + EXPECT_EQ(framePtr->height, 240); + } + provider.close(); +} + +TEST_F(VideoWriterTest, WriteFramesWithMovContainer) { + ccap::VideoWriter writer; + ccap::WriterConfig config; + config.width = 320; + config.height = 240; + config.frameRate = 30.0; + config.bitRate = 2000000; + config.container = ccap::VideoFormat::MOV; + + fs::path outputPath = getTestOutputPath("mov_container"); + // Change extension + outputPath.replace_extension(".mov"); + + ASSERT_TRUE(writer.open(outputPath.string(), config)); + + int w = 320, h = 240; + int stride = w * 3; + std::vector frameData = createBgrFrame(w, h, stride); + + ccap::VideoFrame frame; + frame.data[0] = frameData.data(); + frame.stride[0] = static_cast(stride); + frame.pixelFormat = ccap::PixelFormat::BGR24; + frame.width = static_cast(w); + frame.height = static_cast(h); + frame.sizeInBytes = static_cast(stride * h); + frame.timestamp = 0; + frame.frameIndex = 0; + frame.orientation = ccap::FrameOrientation::Default; + + // Write 10 frames + for (int i = 0; i < 10; i++) { + frame.frameIndex = static_cast(i); + EXPECT_TRUE(writer.writeFrame(frame)); + } + + writer.close(); + EXPECT_TRUE(fs::exists(outputPath)); + EXPECT_GT(fs::file_size(outputPath), 0); +} + +TEST_F(VideoWriterTest, CodecFallback) { + ccap::VideoWriter writer; + ccap::WriterConfig config; + config.width = 320; + config.height = 240; + config.frameRate = 30.0; + config.bitRate = 2000000; + config.codec = ccap::VideoCodec::HEVC; // Request HEVC + + fs::path outputPath = getTestOutputPath("codec_fallback"); + ASSERT_TRUE(writer.open(outputPath.string(), config)); + + // Actual codec may differ from requested due to fallback + ccap::VideoCodec actual = writer.actualCodec(); + EXPECT_TRUE(actual == ccap::VideoCodec::HEVC || actual == ccap::VideoCodec::H264); + + writer.close(); +} + +TEST_F(VideoWriterTest, WriteAfterCloseFails) { + ccap::VideoWriter writer; + ccap::WriterConfig config; + config.width = 320; + config.height = 240; + config.frameRate = 30.0; + config.bitRate = 2000000; + + fs::path outputPath = getTestOutputPath("write_after_close"); + ASSERT_TRUE(writer.open(outputPath.string(), config)); + writer.close(); + + // Writing after close should fail + ccap::VideoFrame frame; + frame.data[0] = nullptr; + frame.pixelFormat = ccap::PixelFormat::BGR24; + frame.width = 320; + frame.height = 240; + + EXPECT_FALSE(writer.writeFrame(frame)); +} + +TEST_F(VideoWriterTest, GetPropertiesAfterOpen) { + ccap::VideoWriter writer; + ccap::WriterConfig config; + config.width = 640; + config.height = 480; + config.frameRate = 25.0; + config.bitRate = 3000000; + + fs::path outputPath = getTestOutputPath("properties"); + ASSERT_TRUE(writer.open(outputPath.string(), config)); + + EXPECT_EQ(writer.width(), 640); + EXPECT_EQ(writer.height(), 480); + EXPECT_DOUBLE_EQ(writer.frameRate(), 25.0); + + writer.close(); + + // After close, properties should return 0 + EXPECT_EQ(writer.width(), 0); + EXPECT_EQ(writer.height(), 0); + EXPECT_DOUBLE_EQ(writer.frameRate(), 0.0); +} + +// ---- C API Tests ---- + +TEST_F(VideoWriterCTest, CreateAndDestroy) { + CcapVideoWriter* writer = ccap_video_writer_create(); + EXPECT_NE(writer, nullptr); + if (writer) { + EXPECT_FALSE(ccap_video_writer_is_opened(writer)); + ccap_video_writer_destroy(writer); + } +} + +TEST_F(VideoWriterCTest, NullHandleSafety) { + // All C functions should handle null gracefully + ccap_video_writer_destroy(nullptr); + EXPECT_FALSE(ccap_video_writer_is_opened(nullptr)); + EXPECT_FALSE(ccap_video_writer_open(nullptr, "test.mp4", nullptr)); + ccap_video_writer_close(nullptr); + EXPECT_FALSE(ccap_video_writer_write_frame(nullptr, nullptr, 0)); + EXPECT_EQ(ccap_video_writer_actual_codec(nullptr), CCAP_VIDEO_CODEC_H264); +} + +TEST_F(VideoWriterCTest, OpenAndWriteFrames) { + CcapVideoWriter* writer = ccap_video_writer_create(); + ASSERT_NE(writer, nullptr); + + CcapWriterConfig config; + config.codec = CCAP_VIDEO_CODEC_HEVC; + config.container = CCAP_VIDEO_FORMAT_MP4; + config.width = 320; + config.height = 240; + config.frameRate = 30.0; + config.bitRate = 2000000; + + fs::path outputPath = getTestOutputPath("c_api"); + ASSERT_TRUE(ccap_video_writer_open(writer, outputPath.string().c_str(), &config)); + EXPECT_TRUE(ccap_video_writer_is_opened(writer)); + + // Create BGR frame + int w = 320, h = 240; + int stride = w * 3; + std::vector frameData = createBgrFrame(w, h, stride); + + CcapVideoFrameInfo frameInfo; + frameInfo.data[0] = frameData.data(); + frameInfo.stride[0] = static_cast(stride); + frameInfo.data[1] = nullptr; + frameInfo.stride[1] = 0; + frameInfo.data[2] = nullptr; + frameInfo.stride[2] = 0; + frameInfo.pixelFormat = CCAP_PIXEL_FORMAT_BGR24; + frameInfo.width = static_cast(w); + frameInfo.height = static_cast(h); + frameInfo.sizeInBytes = static_cast(stride * h); + frameInfo.timestamp = 0; + frameInfo.frameIndex = 0; + frameInfo.orientation = CCAP_FRAME_ORIENTATION_TOP_TO_BOTTOM; + + // Write 15 frames + for (int i = 0; i < 15; i++) { + frameInfo.frameIndex = static_cast(i); + EXPECT_TRUE(ccap_video_writer_write_frame(writer, &frameInfo, 0)); + } + + // Check actual codec + CcapVideoCodec actualCodec = ccap_video_writer_actual_codec(writer); + EXPECT_TRUE(actualCodec == CCAP_VIDEO_CODEC_HEVC || actualCodec == CCAP_VIDEO_CODEC_H264); + + ccap_video_writer_close(writer); + EXPECT_FALSE(ccap_video_writer_is_opened(writer)); + + ccap_video_writer_destroy(writer); + + // Verify file + EXPECT_TRUE(fs::exists(outputPath)); + EXPECT_GT(fs::file_size(outputPath), 0); +} + +TEST_F(VideoWriterCTest, InvalidOpenParams) { + CcapVideoWriter* writer = ccap_video_writer_create(); + ASSERT_NE(writer, nullptr); + + CcapWriterConfig config; + config.codec = CCAP_VIDEO_CODEC_H264; + config.container = CCAP_VIDEO_FORMAT_MP4; + config.width = 320; + config.height = 240; + config.frameRate = 30.0; + config.bitRate = 2000000; + + // Null filePath + EXPECT_FALSE(ccap_video_writer_open(writer, nullptr, &config)); + + // Null config + EXPECT_FALSE(ccap_video_writer_open(writer, "test.mp4", nullptr)); + + ccap_video_writer_destroy(writer); +} From 3d57d3b580d09e10deaf2b7b9d8cc859bc4b4028 Mon Sep 17 00:00:00 2001 From: wy Date: Sat, 16 May 2026 19:49:04 +0800 Subject: [PATCH 02/10] fix: restructure video writer to fix Windows build and address review comments Major changes: - Replace ccap_writer.mm with ccap_writer.cpp (pure C++ dispatch layer) using factory function pattern instead of #include of .mm/.cpp files - Compile platform implementations as separate source files (Apple-only .mm, Windows-only .cpp) matching the existing file reader pattern - Rewrite Windows implementation to use MFCreateSinkWriterFromURL (correct API) instead of non-existent MFCreateMediaSinkForURL - Guard #pragma comment(lib) behind _MSC_VER for MinGW compatibility Review comment fixes: - Respect WriterConfig::codec preference (try requested codec first) - Replace usleep() with std::this_thread::sleep_for (portability) - Use high-precision timescale (600000) for CMTime instead of truncating to integer fps - Add reportError() calls throughout (not just CCAP_LOG_E) - Validate frame dimensions match configured output in writeFrame() - Require even dimensions in open() for NV12 encoding - Check m_mfInitialized in Windows open() before calling MF APIs - Fix C API timestamp: pass resolved timestamp to writeFrame() - Share NV12 conversion code via inline helpers in ccap_writer_imp.h - Support I420/BGRA32 conversion on Windows (was BGR24/NV12 only) - Guard playback validation in tests behind CCAP_ENABLE_FILE_PLAYBACK - Zero-initialize VideoFrame structs in tests - Use std::error_code overloads in test TearDown() for robustness - Update frameRate docs to reflect actual behavior (default 30fps) - Set sample duration on Windows for better seek/playback behavior - Run format_all.sh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CMakeLists.txt | 10 +- include/ccap_writer.h | 2 +- include/ccap_writer_c.h | 2 +- src/ccap_imp_linux.h | 4 +- src/ccap_imp_windows.cpp | 4 +- src/ccap_imp_windows.h | 2 +- src/{ccap_writer.mm => ccap_writer.cpp} | 29 +-- src/ccap_writer_apple.mm | 173 +++++---------- src/ccap_writer_c.cpp | 12 +- src/ccap_writer_imp.h | 91 +++++++- src/ccap_writer_windows.cpp | 267 +++++++++++------------- tests/test_video_writer.cpp | 29 ++- 12 files changed, 296 insertions(+), 329 deletions(-) rename src/{ccap_writer.mm => ccap_writer.cpp} (83%) diff --git a/CMakeLists.txt b/CMakeLists.txt index cb9d8155..f4674309 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -135,15 +135,20 @@ endif () # Video writer sources (Windows and macOS only) option(CCAP_ENABLE_VIDEO_WRITER "Enable video file writing support (Windows/macOS)" ON) if (CCAP_ENABLE_VIDEO_WRITER AND (APPLE OR WIN32)) - # Exclude writer sources from main glob to avoid double-compilation (platform impl included via #include) + # Exclude writer sources from main glob to avoid double-compilation list(FILTER LIB_SOURCE EXCLUDE REGEX ".*ccap_writer_apple.*") list(FILTER LIB_SOURCE EXCLUDE REGEX ".*ccap_writer_windows.*") list(FILTER LIB_SOURCE EXCLUDE REGEX ".*ccap_writer_c\..*$") list(FILTER LIB_SOURCE EXCLUDE REGEX ".*ccap_writer\..*$") list(APPEND LIB_SOURCE - ${CMAKE_CURRENT_SOURCE_DIR}/src/ccap_writer.mm + ${CMAKE_CURRENT_SOURCE_DIR}/src/ccap_writer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/ccap_writer_c.cpp ) + if (APPLE) + list(APPEND LIB_SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/src/ccap_writer_apple.mm) + elseif (WIN32) + list(APPEND LIB_SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/src/ccap_writer_windows.cpp) + endif () message(STATUS "ccap: Video file writing support enabled") else () message(STATUS "ccap: Video file writing support disabled (unsupported platform or disabled)") @@ -152,7 +157,6 @@ endif () if (APPLE) file(GLOB LIB_SOURCE_MAC ${CMAKE_CURRENT_SOURCE_DIR}/src/*.mm) list(FILTER LIB_SOURCE_MAC EXCLUDE REGEX ".*ccap_writer_apple.*") - list(FILTER LIB_SOURCE_MAC EXCLUDE REGEX ".*ccap_writer\.mm$") message(STATUS "ccap: Using Objective-C++ for macOS: ${LIB_SOURCE_MAC}") list(APPEND LIB_SOURCE ${LIB_SOURCE_MAC}) endif () diff --git a/include/ccap_writer.h b/include/ccap_writer.h index bdb00f8e..28da89f7 100644 --- a/include/ccap_writer.h +++ b/include/ccap_writer.h @@ -47,7 +47,7 @@ struct WriterConfig { VideoFormat container = VideoFormat::MP4; uint32_t width = 0; ///< Frame width in pixels uint32_t height = 0; ///< Frame height in pixels - double frameRate = 30.0; ///< Target frame rate; 0 = variable rate + double frameRate = 30.0; ///< Target frame rate (default 30fps; used for timestamp generation when timestampNs is 0) uint64_t bitRate = 5'000'000; ///< Target bit rate in bits/s; 0 = auto }; diff --git a/include/ccap_writer_c.h b/include/ccap_writer_c.h index e913291b..e5f03150 100644 --- a/include/ccap_writer_c.h +++ b/include/ccap_writer_c.h @@ -45,7 +45,7 @@ typedef struct { CcapVideoFormat container; ///< Container format uint32_t width; ///< Frame width uint32_t height; ///< Frame height - double frameRate; ///< Target frame rate (0 = variable) + double frameRate; ///< Target frame rate (default 30fps) uint64_t bitRate; ///< Target bit rate in bits/s (0 = auto) } CcapWriterConfig; diff --git a/src/ccap_imp_linux.h b/src/ccap_imp_linux.h index 8b4cb9f6..ec548de2 100644 --- a/src/ccap_imp_linux.h +++ b/src/ccap_imp_linux.h @@ -101,12 +101,12 @@ class ProviderV4L2 : public ProviderImp { bool m_isStreaming = false; // V4L2 device capabilities - struct v4l2_capability m_caps {}; + struct v4l2_capability m_caps{}; std::vector m_supportedFormats; std::vector m_supportedResolutions; // Current format - struct v4l2_format m_currentFormat {}; + struct v4l2_format m_currentFormat{}; // Buffer management std::vector m_buffers; diff --git a/src/ccap_imp_windows.cpp b/src/ccap_imp_windows.cpp index ab4efe7e..cdeb77ad 100644 --- a/src/ccap_imp_windows.cpp +++ b/src/ccap_imp_windows.cpp @@ -1003,7 +1003,7 @@ HRESULT STDMETHODCALLTYPE ProviderDirectShow::BufferCB(double SampleTime, BYTE* return S_OK; } -HRESULT STDMETHODCALLTYPE ProviderDirectShow::QueryInterface(REFIID riid, _COM_Outptr_ void __RPC_FAR* __RPC_FAR* ppvObject) { +HRESULT STDMETHODCALLTYPE ProviderDirectShow::QueryInterface(REFIID riid, _COM_Outptr_ void __RPC_FAR * __RPC_FAR * ppvObject) { static constexpr const IID IID_ISampleGrabberCB = { 0x0579154A, 0x2B53, 0x4994, { 0xB0, 0xD0, 0xE7, 0x73, 0x14, 0x8E, 0xFF, 0x85 } }; if (riid == IID_IUnknown) { @@ -1168,7 +1168,7 @@ void ProviderDirectShow::close() { bool ProviderDirectShow::start() { if (!m_isOpened) return false; - // File mode + // File mode #ifdef CCAP_ENABLE_FILE_PLAYBACK if (m_isFileMode && m_fileReader) { return m_fileReader->start(); diff --git a/src/ccap_imp_windows.h b/src/ccap_imp_windows.h index 6e4dd02f..fe45ab6d 100644 --- a/src/ccap_imp_windows.h +++ b/src/ccap_imp_windows.h @@ -93,7 +93,7 @@ class ProviderDirectShow : public ProviderImp, public ISampleGrabberCB { inline FrameOrientation frameOrientation() const { return m_frameOrientation; } private: - HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, _COM_Outptr_ void __RPC_FAR* __RPC_FAR* ppvObject) override; + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, _COM_Outptr_ void __RPC_FAR * __RPC_FAR * ppvObject) override; ULONG STDMETHODCALLTYPE AddRef(void) override; ULONG STDMETHODCALLTYPE Release(void) override; diff --git a/src/ccap_writer.mm b/src/ccap_writer.cpp similarity index 83% rename from src/ccap_writer.mm rename to src/ccap_writer.cpp index 50cc5a30..d28bfb46 100644 --- a/src/ccap_writer.mm +++ b/src/ccap_writer.cpp @@ -1,43 +1,22 @@ /** - * @file ccap_writer.mm + * @file ccap_writer.cpp * @author wysaid (this@wysaid.org) - * @brief Video writer platform dispatch layer. + * @brief Video writer platform dispatch layer (pure C++). * @date 2025-05 */ #include "ccap_writer.h" -#include "ccap_writer_imp.h" -#include "ccap_imp.h" - -#include "ccap_convert.h" -#include "ccap_utils.h" -#include -#include -#include +#include "ccap_writer_imp.h" #ifdef CCAP_ENABLE_VIDEO_WRITER -#if __APPLE__ -#include "ccap_writer_apple.mm" -#elif defined(_WIN32) || defined(_MSC_VER) -#include "ccap_writer_windows.cpp" -#endif - namespace ccap { -// ---- Platform dispatch ---- - static VideoWriter::Impl* impl(void* p) { return reinterpret_cast(p); } static const VideoWriter::Impl* impl(const void* p) { return reinterpret_cast(p); } -VideoWriter::VideoWriter() : m_impl(nullptr) { -#if __APPLE__ - m_impl = new WriterApple(); -#elif defined(_WIN32) || defined(_MSC_VER) - m_impl = new WriterWindows(); -#endif -} +VideoWriter::VideoWriter() : m_impl(createVideoWriterImpl()) {} VideoWriter::~VideoWriter() { delete impl(m_impl); diff --git a/src/ccap_writer_apple.mm b/src/ccap_writer_apple.mm index 87bac3db..ab63d377 100644 --- a/src/ccap_writer_apple.mm +++ b/src/ccap_writer_apple.mm @@ -15,7 +15,9 @@ #import #include +#include #include +#include #include namespace ccap { @@ -31,36 +33,46 @@ bool open(std::string_view filePath, const WriterConfig& config) override { if (config.width == 0 || config.height == 0) { - CCAP_LOG_E("Invalid dimensions: %ux%u\n", config.width, config.height); + reportError(ErrorCode::WriterOpenFailed, "Invalid dimensions: " + std::to_string(config.width) + "x" + std::to_string(config.height)); + return false; + } + if (config.width % 2 != 0 || config.height % 2 != 0) { + reportError(ErrorCode::WriterOpenFailed, "Video dimensions must be even for NV12 encoding: " + std::to_string(config.width) + "x" + std::to_string(config.height)); return false; } m_config = config; NSString* pathStr = [NSString stringWithUTF8String: std::string(filePath).c_str()]; - // Determine output file type - AVFileType fileType = AVFileTypeMPEG4; // MP4 + AVFileType fileType = AVFileTypeMPEG4; if (config.container == VideoFormat::MOV) { fileType = AVFileTypeQuickTimeMovie; } - // Try HEVC first, fallback to H.264 - AVVideoCodecType codecs[] = { AVVideoCodecTypeHEVC, AVVideoCodecTypeH264 }; - VideoCodec cppCodecs[] = { VideoCodec::HEVC, VideoCodec::H264 }; + // Try requested codec first, then fallback + AVVideoCodecType codecs[2]; + VideoCodec cppCodecs[2]; + if (config.codec == VideoCodec::H264) { + codecs[0] = AVVideoCodecTypeH264; cppCodecs[0] = VideoCodec::H264; + codecs[1] = AVVideoCodecTypeHEVC; cppCodecs[1] = VideoCodec::HEVC; + } else { + codecs[0] = AVVideoCodecTypeHEVC; cppCodecs[0] = VideoCodec::HEVC; + codecs[1] = AVVideoCodecTypeH264; cppCodecs[1] = VideoCodec::H264; + } for (int i = 0; i < 2; i++) { - if (tryOpen(filePath, fileType, pathStr, codecs[i])) { + if (tryOpen(fileType, pathStr, codecs[i])) { m_actualCodec = cppCodecs[i]; return true; } } - CCAP_LOG_E("Failed to create video writer with HEVC or H.264\n"); + reportError(ErrorCode::WriterOpenFailed, "Failed to create video writer with any supported codec"); return false; } private: - bool tryOpen(std::string_view, AVFileType fileType, NSString* pathStr, AVVideoCodecType codec) { + bool tryOpen(AVFileType fileType, NSString* pathStr, AVVideoCodecType codec) { NSURL* url = [NSURL fileURLWithPath: pathStr]; NSError* error = nil; int64_t bitRate = (m_config.bitRate > 0) ? static_cast(m_config.bitRate) : static_cast(m_config.width) * m_config.height * 4; @@ -101,7 +113,6 @@ bool tryOpen(std::string_view, AVFileType fileType, NSString* pathStr, AVVideoCo m_writerInput.expectsMediaDataInRealTime = NO; [m_assetWriter addInput: m_writerInput]; - // CRITICAL: Pixel buffer adaptor MUST be created before startWriting NSDictionary* pixelBufferAttrs = @{ (id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange), (id)kCVPixelBufferWidthKey: @(m_config.width), @@ -128,7 +139,6 @@ bool tryOpen(std::string_view, AVFileType fileType, NSString* pathStr, AVVideoCo return false; } - // Start session at time 0 so we can append samples immediately [m_assetWriter startSessionAtSourceTime: CMTimeMake(0, 1)]; m_sessionStarted = YES; m_frameCount = 0; @@ -155,8 +165,6 @@ void close() override { [m_writerInput markAsFinished]; } if (m_assetWriter) { - // Use a background queue to avoid blocking the calling thread - // which allows the completion handler to execute dispatch_queue_t queue = dispatch_queue_create("com.ccap.writer.close", DISPATCH_QUEUE_SERIAL); dispatch_semaphore_t sem = dispatch_semaphore_create(0); dispatch_async(queue, ^{ @@ -164,17 +172,15 @@ void close() override { dispatch_semaphore_signal(sem); }]; }); - // Wait for completion dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); if (m_assetWriter.error) { - CCAP_LOG_E("finishWriting failed: %s\n", - m_assetWriter.error.localizedDescription.UTF8String); + reportError(ErrorCode::WriterCloseFailed, "finishWriting failed: " + std::string(m_assetWriter.error.localizedDescription.UTF8String)); } } } @catch (NSException* e) { - CCAP_LOG_E("Exception during writer close: %s\n", e.reason.UTF8String); + reportError(ErrorCode::WriterCloseFailed, "Exception during writer close: " + std::string(e.reason.UTF8String)); } m_pixelBufferAdaptor = nil; @@ -190,64 +196,34 @@ bool isOpened() const override { bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) override { if (!m_isOpened || !m_writerInput || !m_assetWriter || !m_pixelBufferAdaptor) return false; + if (frame.width != m_config.width || frame.height != m_config.height) { + reportError(ErrorCode::WriterWriteFailed, "Frame dimensions " + std::to_string(frame.width) + "x" + std::to_string(frame.height) + + " do not match configured " + std::to_string(m_config.width) + "x" + std::to_string(m_config.height)); + return false; + } + @try { // Wait for writer input to be ready (with 2 second timeout) int waitMs = 0; while (![m_writerInput isReadyForMoreMediaData]) { - usleep(1000); // 1ms + std::this_thread::sleep_for(std::chrono::milliseconds(1)); if (++waitMs > 2000) { - CCAP_LOG_W("Writer input not ready after 2s, dropping frame\n"); + reportError(ErrorCode::WriterWriteFailed, "Writer input not ready after 2s, dropping frame"); return false; } } - int w = static_cast(frame.width); - int h = static_cast(frame.height); - int w2 = (w + 1) / 2; - int h2 = (h + 1) / 2; + const int w = static_cast(frame.width); + const int h = static_cast(frame.height); + const int w2 = w / 2; + const int h2 = h / 2; // Convert frame to NV12 std::vector yBuf, uvBuf; uint32_t yStride, uvStride; - { - yStride = static_cast(w); - uvStride = static_cast(w2 * 2); - yBuf.resize(static_cast(yStride) * h); - uvBuf.resize(static_cast(uvStride) * h2); - - uint8_t* dstYTmp = yBuf.data(); - uint8_t* dstUVTmp = uvBuf.data(); - - if (frame.pixelFormat == PixelFormat::NV12 || frame.pixelFormat == PixelFormat::NV12f) { - for (int y = 0; y < h; y++) { - memcpy(dstYTmp + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); - } - for (int y = 0; y < h2; y++) { - memcpy(dstUVTmp + y * uvStride, frame.data[1] + y * frame.stride[1], static_cast(w2) * 2); - } - } else if (frame.pixelFormat == PixelFormat::I420 || frame.pixelFormat == PixelFormat::I420f) { - for (int y = 0; y < h; y++) { - memcpy(dstYTmp + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); - } - for (int y = 0; y < h2; y++) { - for (int x = 0; x < w2; x++) { - dstUVTmp[y * uvStride + x * 2] = frame.data[1][y * frame.stride[1] + x]; - dstUVTmp[y * uvStride + x * 2 + 1] = frame.data[2][y * frame.stride[2] + x]; - } - } - } else if (frame.pixelFormat == PixelFormat::BGR24) { - bgr24ToNv12(frame.data[0], static_cast(frame.stride[0]), - dstYTmp, static_cast(yStride), - dstUVTmp, static_cast(uvStride), w, h); - } else if (frame.pixelFormat == PixelFormat::BGRA32) { - bgra32ToNv12(frame.data[0], static_cast(frame.stride[0]), - dstYTmp, static_cast(yStride), - dstUVTmp, static_cast(uvStride), w, h); - } else { - CCAP_LOG_E("Unsupported pixel format for writer on macOS: %d\n", - static_cast(frame.pixelFormat)); - return false; - } + if (!convertFrameToNv12(frame, yBuf, uvBuf, yStride, uvStride)) { + reportError(ErrorCode::WriterWriteFailed, "Unsupported pixel format: " + std::to_string(static_cast(frame.pixelFormat))); + return false; } // Create CVPixelBuffer @@ -256,7 +232,7 @@ bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) override { kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, nullptr, &pixelBuffer); if (ret != kCVReturnSuccess) { - CCAP_LOG_E("CVPixelBufferCreate failed: %d\n", ret); + reportError(ErrorCode::WriterWriteFailed, "CVPixelBufferCreate failed: " + std::to_string(ret)); return false; } @@ -276,13 +252,15 @@ bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) override { CVPixelBufferUnlockBaseAddress(pixelBuffer, 0); - // Calculate timestamp + // Calculate timestamp using a high timescale for precision + static constexpr int32_t kTimeScale = 600 * 1000; // 600000 supports common frame rates accurately CMTime presentationTime; if (timestampNs > 0) { - presentationTime = CMTimeMake(static_cast(timestampNs), 1000000000); + presentationTime = CMTimeMake(static_cast(timestampNs / 1000000.0 * kTimeScale / 1000.0), kTimeScale); } else { double fps = m_config.frameRate > 0 ? m_config.frameRate : 30.0; - presentationTime = CMTimeMake(static_cast(m_frameCount), static_cast(fps)); + int64_t timeValue = static_cast(m_frameCount * (static_cast(kTimeScale) / fps)); + presentationTime = CMTimeMake(timeValue, kTimeScale); } // Append pixel buffer via adaptor @@ -291,8 +269,8 @@ bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) override { CVPixelBufferRelease(pixelBuffer); if (!success) { - CCAP_LOG_E("appendPixelBuffer failed: %s\n", - m_assetWriter.error ? m_assetWriter.error.localizedDescription.UTF8String : "unknown"); + reportError(ErrorCode::WriterWriteFailed, "appendPixelBuffer failed: " + + std::string(m_assetWriter.error ? m_assetWriter.error.localizedDescription.UTF8String : "unknown")); return false; } @@ -300,65 +278,12 @@ bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) override { return true; } @catch (NSException* e) { - CCAP_LOG_E("Exception during writeFrame: %s\n", e.reason.UTF8String); + reportError(ErrorCode::WriterWriteFailed, "Exception during writeFrame: " + std::string(e.reason.UTF8String)); return false; } } private: - // Inline conversion helpers - void bgr24ToNv12(const uint8_t* src, int srcStride, - uint8_t* dstY, int dstYStride, - uint8_t* dstUV, int dstUVStride, - int width, int height) { - int w2 = width / 2; - const uint8_t* line = src; - for (int y = 0; y < height; y += 2) { - const uint8_t* line0 = line; - const uint8_t* line1 = (y + 1 < height) ? line + srcStride : line; - for (int x = 0; x < w2; x++) { - int b0 = line0[x*6+0], g0 = line0[x*6+1], r0 = line0[x*6+2]; - int b1 = line0[x*6+3], g1 = line0[x*6+4], r1 = line0[x*6+5]; - int b2 = line1[x*6+0], g2 = line1[x*6+1], r2 = line1[x*6+2]; - int b3 = line1[x*6+3], g3 = line1[x*6+4], r3 = line1[x*6+5]; - dstY[y * dstYStride + x*2] = static_cast((66*r0+129*g0+25*b0+128)>>8)+16; - dstY[y * dstYStride + x*2+1] = static_cast((66*r1+129*g1+25*b1+128)>>8)+16; - dstY[(y+1) * dstYStride + x*2] = static_cast((66*r2+129*g2+25*b2+128)>>8)+16; - dstY[(y+1) * dstYStride + x*2+1] = static_cast((66*r3+129*g3+25*b3+128)>>8)+16; - int bAvg = (b0+b1+b2+b3)/4, rAvg = (r0+r1+r2+r3)/4, gAvg = (g0+g1+g2+g3)/4; - dstUV[(y/2) * dstUVStride + x*2] = static_cast((-38*rAvg-74*gAvg+112*bAvg+128)>>8)+128; - dstUV[(y/2) * dstUVStride + x*2+1] = static_cast((112*rAvg-94*gAvg-18*bAvg+128)>>8)+128; - } - line += srcStride * 2; - } - } - - void bgra32ToNv12(const uint8_t* src, int srcStride, - uint8_t* dstY, int dstYStride, - uint8_t* dstUV, int dstUVStride, - int width, int height) { - int w2 = width / 2; - const uint8_t* line = src; - for (int y = 0; y < height; y += 2) { - const uint8_t* line0 = line; - const uint8_t* line1 = (y + 1 < height) ? line + srcStride : line; - for (int x = 0; x < w2; x++) { - int b0 = line0[x*8+0], g0 = line0[x*8+1], r0 = line0[x*8+2]; - int b1 = line0[x*8+4], g1 = line0[x*8+5], r1 = line0[x*8+6]; - int b2 = line1[x*8+0], g2 = line1[x*8+1], r2 = line1[x*8+2]; - int b3 = line1[x*8+4], g3 = line1[x*8+5], r3 = line1[x*8+6]; - dstY[y * dstYStride + x*2] = static_cast((66*r0+129*g0+25*b0+128)>>8)+16; - dstY[y * dstYStride + x*2+1] = static_cast((66*r1+129*g1+25*b1+128)>>8)+16; - dstY[(y+1) * dstYStride + x*2] = static_cast((66*r2+129*g2+25*b2+128)>>8)+16; - dstY[(y+1) * dstYStride + x*2+1] = static_cast((66*r3+129*g3+25*b3+128)>>8)+16; - int bAvg = (b0+b1+b2+b3)/4, rAvg = (r0+r1+r2+r3)/4, gAvg = (g0+g1+g2+g3)/4; - dstUV[(y/2) * dstUVStride + x*2] = static_cast((-38*rAvg-74*gAvg+112*bAvg+128)>>8)+128; - dstUV[(y/2) * dstUVStride + x*2+1] = static_cast((112*rAvg-94*gAvg-18*bAvg+128)>>8)+128; - } - line += srcStride * 2; - } - } - AVAssetWriter* m_assetWriter; AVAssetWriterInput* m_writerInput; AVAssetWriterInputPixelBufferAdaptor* m_pixelBufferAdaptor; @@ -367,6 +292,10 @@ void bgra32ToNv12(const uint8_t* src, int srcStride, std::atomic m_frameCount{0}; }; +VideoWriter::Impl* createVideoWriterImpl() { + return new WriterApple(); +} + } // namespace ccap #endif // __APPLE__ diff --git a/src/ccap_writer_c.cpp b/src/ccap_writer_c.cpp index 8149f51f..f5083f04 100644 --- a/src/ccap_writer_c.cpp +++ b/src/ccap_writer_c.cpp @@ -35,10 +35,8 @@ bool ccap_video_writer_open(CcapVideoWriter* writer, const char* filePath, auto* cppWriter = reinterpret_cast(writer); ccap::WriterConfig cppConfig; - cppConfig.codec = (config->codec == CCAP_VIDEO_CODEC_HEVC) - ? ccap::VideoCodec::HEVC : ccap::VideoCodec::H264; - cppConfig.container = (config->container == CCAP_VIDEO_FORMAT_MOV) - ? ccap::VideoFormat::MOV : ccap::VideoFormat::MP4; + cppConfig.codec = (config->codec == CCAP_VIDEO_CODEC_HEVC) ? ccap::VideoCodec::HEVC : ccap::VideoCodec::H264; + cppConfig.container = (config->container == CCAP_VIDEO_FORMAT_MOV) ? ccap::VideoFormat::MOV : ccap::VideoFormat::MP4; cppConfig.width = config->width; cppConfig.height = config->height; cppConfig.frameRate = config->frameRate; @@ -85,7 +83,8 @@ bool ccap_video_writer_write_frame(CcapVideoWriter* writer, frame.frameIndex = frameInfo->frameIndex; frame.orientation = static_cast(static_cast(frameInfo->orientation)); - return cppWriter->writeFrame(frame, timestampNs); + uint64_t resolvedTimestamp = timestampNs > 0 ? timestampNs : frameInfo->timestamp; + return cppWriter->writeFrame(frame, resolvedTimestamp); } catch (...) { return false; } @@ -94,8 +93,7 @@ bool ccap_video_writer_write_frame(CcapVideoWriter* writer, CcapVideoCodec ccap_video_writer_actual_codec(const CcapVideoWriter* writer) { if (!writer) return CCAP_VIDEO_CODEC_H264; auto* cppWriter = reinterpret_cast(writer); - return (cppWriter->actualCodec() == ccap::VideoCodec::HEVC) - ? CCAP_VIDEO_CODEC_HEVC : CCAP_VIDEO_CODEC_H264; + return (cppWriter->actualCodec() == ccap::VideoCodec::HEVC) ? CCAP_VIDEO_CODEC_HEVC : CCAP_VIDEO_CODEC_H264; } } // extern "C" diff --git a/src/ccap_writer_imp.h b/src/ccap_writer_imp.h index 5b58eebc..13e9f643 100644 --- a/src/ccap_writer_imp.h +++ b/src/ccap_writer_imp.h @@ -8,13 +8,17 @@ #ifndef CCAP_WRITER_IMP_H #define CCAP_WRITER_IMP_H -#include "ccap_writer.h" #include "ccap_def.h" +#include "ccap_writer.h" +#include #include +#include namespace ccap { +void reportError(ErrorCode errorCode, std::string_view description); + struct VideoWriter::Impl { Impl() : m_actualCodec(VideoCodec::H264) {} virtual ~Impl() = default; @@ -28,6 +32,91 @@ struct VideoWriter::Impl { WriterConfig m_config; }; +/// Factory function implemented per platform (Apple / Windows). +/// Returns nullptr on unsupported platforms. +VideoWriter::Impl* createVideoWriterImpl(); + +// ---- Shared NV12 conversion helpers (used by both platform implementations) ---- + +inline void bgrToNv12(const uint8_t* src, int srcStride, + uint8_t* dstY, int dstYStride, + uint8_t* dstUV, int dstUVStride, + int width, int height, int bytesPerPixel) { + // bytesPerPixel: 3 for BGR24, 4 for BGRA32 + const int w2 = width / 2; + for (int y = 0; y < height; y += 2) { + const uint8_t* line0 = src + y * srcStride; + const uint8_t* line1 = (y + 1 < height) ? src + (y + 1) * srcStride : line0; + for (int x = 0; x < w2; x++) { + const int off = x * 2 * bytesPerPixel; + int b0 = line0[off], g0 = line0[off + 1], r0 = line0[off + 2]; + int b1 = line0[off + bytesPerPixel], g1 = line0[off + bytesPerPixel + 1], r1 = line0[off + bytesPerPixel + 2]; + int b2 = line1[off], g2 = line1[off + 1], r2 = line1[off + 2]; + int b3 = line1[off + bytesPerPixel], g3 = line1[off + bytesPerPixel + 1], r3 = line1[off + bytesPerPixel + 2]; + dstY[y * dstYStride + x * 2] = static_cast(((66 * r0 + 129 * g0 + 25 * b0 + 128) >> 8) + 16); + dstY[y * dstYStride + x * 2 + 1] = static_cast(((66 * r1 + 129 * g1 + 25 * b1 + 128) >> 8) + 16); + dstY[(y + 1) * dstYStride + x * 2] = static_cast(((66 * r2 + 129 * g2 + 25 * b2 + 128) >> 8) + 16); + dstY[(y + 1) * dstYStride + x * 2 + 1] = static_cast(((66 * r3 + 129 * g3 + 25 * b3 + 128) >> 8) + 16); + int bAvg = (b0 + b1 + b2 + b3) / 4, rAvg = (r0 + r1 + r2 + r3) / 4, gAvg = (g0 + g1 + g2 + g3) / 4; + dstUV[(y / 2) * dstUVStride + x * 2] = static_cast(((-38 * rAvg - 74 * gAvg + 112 * bAvg + 128) >> 8) + 128); + dstUV[(y / 2) * dstUVStride + x * 2 + 1] = static_cast(((112 * rAvg - 94 * gAvg - 18 * bAvg + 128) >> 8) + 128); + } + } +} + +/// Convert any supported pixel format to NV12 Y and UV planes. +/// Returns false on unsupported format. Requires even width/height. +inline bool convertFrameToNv12(const VideoFrame& frame, + std::vector& yBuf, std::vector& uvBuf, + uint32_t& yStride, uint32_t& uvStride) { + const int w = static_cast(frame.width); + const int h = static_cast(frame.height); + const int w2 = w / 2; + const int h2 = h / 2; + + yStride = static_cast(w); + uvStride = static_cast(w2 * 2); + yBuf.resize(static_cast(yStride) * h); + uvBuf.resize(static_cast(uvStride) * h2); + + switch (frame.pixelFormat) { + case PixelFormat::NV12: + case PixelFormat::NV12f: + for (int y = 0; y < h; y++) + std::memcpy(yBuf.data() + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); + for (int y = 0; y < h2; y++) + std::memcpy(uvBuf.data() + y * uvStride, frame.data[1] + y * frame.stride[1], static_cast(w2) * 2); + return true; + + case PixelFormat::I420: + case PixelFormat::I420f: + for (int y = 0; y < h; y++) + std::memcpy(yBuf.data() + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); + for (int y = 0; y < h2; y++) { + for (int x = 0; x < w2; x++) { + uvBuf[y * uvStride + x * 2] = frame.data[1][y * frame.stride[1] + x]; + uvBuf[y * uvStride + x * 2 + 1] = frame.data[2][y * frame.stride[2] + x]; + } + } + return true; + + case PixelFormat::BGR24: + bgrToNv12(frame.data[0], static_cast(frame.stride[0]), + yBuf.data(), static_cast(yStride), + uvBuf.data(), static_cast(uvStride), w, h, 3); + return true; + + case PixelFormat::BGRA32: + bgrToNv12(frame.data[0], static_cast(frame.stride[0]), + yBuf.data(), static_cast(yStride), + uvBuf.data(), static_cast(uvStride), w, h, 4); + return true; + + default: + return false; + } +} + } // namespace ccap #endif // CCAP_WRITER_IMP_H diff --git a/src/ccap_writer_windows.cpp b/src/ccap_writer_windows.cpp index 0e9a3962..0e6775d8 100644 --- a/src/ccap_writer_windows.cpp +++ b/src/ccap_writer_windows.cpp @@ -5,29 +5,30 @@ * @date 2025-05 */ -#include "ccap_writer_imp.h" #include "ccap_utils.h" +#include "ccap_writer_imp.h" #if defined(_WIN32) || defined(_MSC_VER) #ifndef NOMINMAX #define NOMINMAX #endif -#include +#include +#include #include +#include #include #include -#include - -#include -#include #include #include +#include +#ifdef _MSC_VER #pragma comment(lib, "mf.lib") #pragma comment(lib, "mfplat.lib") #pragma comment(lib, "mfreadwrite.lib") #pragma comment(lib, "mfuuid.lib") +#endif namespace ccap { @@ -36,6 +37,9 @@ class WriterWindows : public VideoWriter::Impl { WriterWindows() : m_sinkWriter(nullptr), m_streamIndex(0), m_mfInitialized(false) { HRESULT hr = MFStartup(MF_VERSION, MFSTARTUP_FULL); m_mfInitialized = SUCCEEDED(hr); + if (!m_mfInitialized) { + CCAP_LOG_E("MFStartup failed: 0x%08lX\n", hr); + } } ~WriterWindows() override { @@ -46,26 +50,51 @@ class WriterWindows : public VideoWriter::Impl { } bool open(std::string_view filePath, const WriterConfig& config) override { + if (!m_mfInitialized) { + reportError(ErrorCode::WriterOpenFailed, "Media Foundation not initialized"); + return false; + } + if (config.width == 0 || config.height == 0) { + reportError(ErrorCode::WriterOpenFailed, "Invalid dimensions: " + std::to_string(config.width) + "x" + std::to_string(config.height)); + return false; + } + if (config.width % 2 != 0 || config.height % 2 != 0) { + reportError(ErrorCode::WriterOpenFailed, "Video dimensions must be even for NV12 encoding: " + std::to_string(config.width) + "x" + std::to_string(config.height)); + return false; + } m_config = config; // Convert path to wide string - std::wstring widePath(filePath.begin(), filePath.end()); - - // Try HEVC first, fallback to H.264 - GUID videoCodec = MFVideoFormat_HEVC; - m_actualCodec = VideoCodec::HEVC; - if (!tryCreateWriter(widePath, videoCodec, config)) { - videoCodec = MFVideoFormat_H264; - m_actualCodec = VideoCodec::H264; - if (!tryCreateWriter(widePath, videoCodec, config)) { - CCAP_LOG_E("Failed to create video writer with H.264 or HEVC\n"); - return false; + int wideLen = MultiByteToWideChar(CP_UTF8, 0, filePath.data(), static_cast(filePath.size()), nullptr, 0); + std::wstring widePath(wideLen, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, filePath.data(), static_cast(filePath.size()), widePath.data(), wideLen); + + // Try requested codec first, then fallback + GUID codecs[2]; + VideoCodec cppCodecs[2]; + if (config.codec == VideoCodec::H264) { + codecs[0] = MFVideoFormat_H264; + cppCodecs[0] = VideoCodec::H264; + codecs[1] = MFVideoFormat_HEVC; + cppCodecs[1] = VideoCodec::HEVC; + } else { + codecs[0] = MFVideoFormat_HEVC; + cppCodecs[0] = VideoCodec::HEVC; + codecs[1] = MFVideoFormat_H264; + cppCodecs[1] = VideoCodec::H264; + } + + for (int i = 0; i < 2; i++) { + if (tryCreateWriter(widePath, codecs[i], config)) { + m_actualCodec = cppCodecs[i]; + m_frameCount = 0; + m_isOpened = true; + return true; } } - m_frameCount = 0; - m_isOpened = true; - return true; + reportError(ErrorCode::WriterOpenFailed, "Failed to create video writer with any supported codec"); + return false; } void close() override { @@ -73,7 +102,10 @@ class WriterWindows : public VideoWriter::Impl { m_isOpened = false; if (m_sinkWriter) { - m_sinkWriter->Finalize(); + HRESULT hr = m_sinkWriter->Finalize(); + if (FAILED(hr)) { + reportError(ErrorCode::WriterCloseFailed, "IMFSinkWriter::Finalize failed: 0x" + std::to_string(hr)); + } m_sinkWriter->Release(); m_sinkWriter = nullptr; } @@ -86,36 +118,39 @@ class WriterWindows : public VideoWriter::Impl { bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) override { if (!m_isOpened || !m_sinkWriter) return false; - int w = static_cast(frame.width); - int h = static_cast(frame.height); - int w2 = (w + 1) / 2; - int h2 = (h + 1) / 2; + if (frame.width != m_config.width || frame.height != m_config.height) { + reportError(ErrorCode::WriterWriteFailed, "Frame dimensions " + std::to_string(frame.width) + "x" + std::to_string(frame.height) + " do not match configured " + std::to_string(m_config.width) + "x" + std::to_string(m_config.height)); + return false; + } + + const int w = static_cast(frame.width); + const int h = static_cast(frame.height); + const int w2 = w / 2; + const int h2 = h / 2; // Convert frame to NV12 std::vector yBuf, uvBuf; uint32_t yStride, uvStride; - if (!convertToNv12(frame, yBuf, uvBuf, yStride, uvStride)) { + if (!convertFrameToNv12(frame, yBuf, uvBuf, yStride, uvStride)) { + reportError(ErrorCode::WriterWriteFailed, "Unsupported pixel format: " + std::to_string(static_cast(frame.pixelFormat))); return false; } - // Create total buffer size: Y + UV - int totalSize = static_cast(yStride) * h + static_cast(uvStride) * h2; + // Total NV12 buffer size + DWORD totalSize = static_cast(yStride) * h + static_cast(uvStride) * h2; // Create sample IMFSample* pSample = nullptr; HRESULT hr = MFCreateSample(&pSample); if (FAILED(hr)) { - CCAP_LOG_E("MFCreateSample failed: 0x%08X\n", hr); + reportError(ErrorCode::WriterWriteFailed, "MFCreateSample failed"); return false; } - IMFSinkWriter* pWriter = m_sinkWriter; - - // Add buffer IMFMediaBuffer* pBuffer = nullptr; - hr = MFCreateMemoryBuffer(static_cast(totalSize), &pBuffer); + hr = MFCreateMemoryBuffer(totalSize, &pBuffer); if (FAILED(hr)) { - CCAP_LOG_E("MFCreateMemoryBuffer failed: 0x%08X\n", hr); + reportError(ErrorCode::WriterWriteFailed, "MFCreateMemoryBuffer failed"); pSample->Release(); return false; } @@ -123,7 +158,6 @@ class WriterWindows : public VideoWriter::Impl { BYTE* pData = nullptr; hr = pBuffer->Lock(&pData, nullptr, nullptr); if (FAILED(hr)) { - CCAP_LOG_E("Buffer Lock failed: 0x%08X\n", hr); pBuffer->Release(); pSample->Release(); return false; @@ -134,32 +168,38 @@ class WriterWindows : public VideoWriter::Impl { memcpy(pData + y * yStride, yBuf.data() + y * yStride, static_cast(w)); } // Copy UV plane - uint8_t* uvStart = pData + static_cast(yStride) * h; + uint8_t* uvStart = pData + static_cast(yStride) * h; for (int y = 0; y < h2; y++) { memcpy(uvStart + y * uvStride, uvBuf.data() + y * uvStride, static_cast(w2) * 2); } pBuffer->Unlock(); - pBuffer->SetCurrentLength(static_cast(totalSize)); + pBuffer->SetCurrentLength(totalSize); pSample->AddBuffer(pBuffer); pBuffer->Release(); - // Set timestamp + // Set timestamp (100ns units) LONGLONG hnsTimestamp; if (timestampNs > 0) { - hnsTimestamp = static_cast(timestampNs / 100); // ns to 100ns + hnsTimestamp = static_cast(timestampNs / 100); } else { double fps = m_config.frameRate > 0 ? m_config.frameRate : 30.0; hnsTimestamp = static_cast(m_frameCount * 10000000.0 / fps); } pSample->SetSampleTime(hnsTimestamp); - // Write sample - hr = pWriter->WriteSample(m_streamIndex, pSample); + // Set sample duration + { + double fps = m_config.frameRate > 0 ? m_config.frameRate : 30.0; + LONGLONG duration = static_cast(10000000.0 / fps); + pSample->SetSampleDuration(duration); + } + + hr = m_sinkWriter->WriteSample(m_streamIndex, pSample); pSample->Release(); if (FAILED(hr)) { - CCAP_LOG_E("WriteSample failed: 0x%08X\n", hr); + reportError(ErrorCode::WriterWriteFailed, "WriteSample failed"); return false; } @@ -168,40 +208,30 @@ class WriterWindows : public VideoWriter::Impl { } private: - bool tryCreateWriter(const std::wstring& filePath, GUID videoCodec, const WriterConfig& config) { + bool tryCreateWriter(const std::wstring& filePath, const GUID& videoCodec, const WriterConfig& config) { + // Use MFCreateSinkWriterFromURL - the standard approach for file writing + IMFSinkWriter* pWriter = nullptr; + IMFAttributes* pAttributes = nullptr; - HRESULT hr = MFCreateAttributes(&pAttributes, 1); + HRESULT hr = MFCreateAttributes(&pAttributes, 2); if (FAILED(hr)) return false; - hr = pAttributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); - if (FAILED(hr)) { - pAttributes->Release(); - return false; - } + pAttributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); + pAttributes->SetUINT32(MF_SINK_WRITER_DISABLE_THROTTLING, TRUE); - IMFAttributes* pEncodingAttributes = nullptr; - if (videoCodec == MFVideoFormat_H264) { - hr = MFCreateAttributes(&pEncodingAttributes, 2); - if (SUCCEEDED(hr)) { - hr = pEncodingAttributes->SetUINT32(MF_LOW_LATENCY, TRUE); - } - } + hr = MFCreateSinkWriterFromURL(filePath.c_str(), nullptr, pAttributes, &pWriter); + pAttributes->Release(); - IMFMediaSink* pSink = nullptr; - hr = MFCreateMediaSinkForURL(filePath.c_str(), pAttributes, &pSink); if (FAILED(hr)) { - CCAP_LOG_E("MFCreateMediaSinkForURL failed: 0x%08X\n", hr); - pAttributes->Release(); - if (pEncodingAttributes) pEncodingAttributes->Release(); + CCAP_LOG_E("MFCreateSinkWriterFromURL failed: 0x%08lX\n", hr); return false; } + // Configure output media type (encoded format) IMFMediaType* pOutputType = nullptr; hr = MFCreateMediaType(&pOutputType); if (FAILED(hr)) { - pSink->Release(); - pAttributes->Release(); - if (pEncodingAttributes) pEncodingAttributes->Release(); + pWriter->Release(); return false; } @@ -209,41 +239,30 @@ class WriterWindows : public VideoWriter::Impl { pOutputType->SetGUID(MF_MT_SUBTYPE, videoCodec); pOutputType->SetUINT32(MF_MT_AVG_BITRATE, static_cast(config.bitRate > 0 ? config.bitRate : config.width * config.height * 4)); pOutputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + MFSetAttributeSize(pOutputType, MF_MT_FRAME_SIZE, config.width, config.height); - // Set frame rate UINT32 fpsNum = static_cast(config.frameRate * 1000); UINT32 fpsDen = 1000; - if (config.frameRate <= 0) { fpsNum = 30000; fpsDen = 1000; } + if (config.frameRate <= 0) { + fpsNum = 30000; + fpsDen = 1000; + } MFSetAttributeRatio(pOutputType, MF_MT_FRAME_RATE, fpsNum, fpsDen); - MFSetAttributeSize(pOutputType, MF_MT_FRAME_SIZE, config.width, config.height); - - hr = pSink->AddStream(pOutputType, &m_streamIndex); + DWORD streamIndex = 0; + hr = pWriter->AddStream(pOutputType, &streamIndex); pOutputType->Release(); if (FAILED(hr)) { - CCAP_LOG_E("AddStream failed: 0x%08X\n", hr); - pSink->Release(); - pAttributes->Release(); - if (pEncodingAttributes) pEncodingAttributes->Release(); - return false; - } - - hr = MFCreateSinkWriterFromMediaSink(pSink, pEncodingAttributes, &m_sinkWriter); - pSink->Release(); - pAttributes->Release(); - if (pEncodingAttributes) pEncodingAttributes->Release(); - - if (FAILED(hr)) { - CCAP_LOG_E("MFCreateSinkWriterFromMediaSink failed: 0x%08X\n", hr); + CCAP_LOG_E("AddStream failed: 0x%08lX\n", hr); + pWriter->Release(); return false; } - // Set input type (NV12) + // Configure input media type (raw NV12) IMFMediaType* pInputType = nullptr; hr = MFCreateMediaType(&pInputType); if (FAILED(hr)) { - m_sinkWriter->Release(); - m_sinkWriter = nullptr; + pWriter->Release(); return false; } @@ -253,86 +272,38 @@ class WriterWindows : public VideoWriter::Impl { MFSetAttributeSize(pInputType, MF_MT_FRAME_SIZE, config.width, config.height); MFSetAttributeRatio(pInputType, MF_MT_FRAME_RATE, fpsNum, fpsDen); - hr = m_sinkWriter->SetInputMediaType(m_streamIndex, pInputType, nullptr); + hr = pWriter->SetInputMediaType(streamIndex, pInputType, nullptr); pInputType->Release(); if (FAILED(hr)) { - CCAP_LOG_E("SetInputMediaType failed: 0x%08X\n", hr); - m_sinkWriter->Release(); - m_sinkWriter = nullptr; + CCAP_LOG_E("SetInputMediaType failed: 0x%08lX\n", hr); + pWriter->Release(); return false; } - // Start writing - hr = m_sinkWriter->BeginWriting(); + hr = pWriter->BeginWriting(); if (FAILED(hr)) { - CCAP_LOG_E("BeginWriting failed: 0x%08X\n", hr); - m_sinkWriter->Release(); - m_sinkWriter = nullptr; + CCAP_LOG_E("BeginWriting failed: 0x%08lX\n", hr); + pWriter->Release(); return false; } - return true; - } - - bool convertToNv12(const VideoFrame& frame, - std::vector& yBuf, std::vector& uvBuf, - uint32_t& yStride, uint32_t& uvStride) { - int w = static_cast(frame.width); - int h = static_cast(frame.height); - int w2 = (w + 1) / 2; - int h2 = (h + 1) / 2; - - yStride = static_cast(w); - uvStride = static_cast(w2 * 2); - - yBuf.resize(static_cast(yStride) * h); - uvBuf.resize(static_cast(uvStride) * h2); - - if (frame.pixelFormat == PixelFormat::BGR24) { - const uint8_t* src = frame.data[0]; - int srcStride = static_cast(frame.stride[0]); - uint8_t* dstY = yBuf.data(); - uint8_t* dstUV = uvBuf.data(); - for (int y = 0; y < h; y += 2) { - const uint8_t* l0 = src + y * srcStride; - const uint8_t* l1 = src + (y + 1 < h ? (y + 1) * srcStride : y * srcStride); - for (int x = 0; x < w2; x++) { - int b0 = l0[x*6+0], g0 = l0[x*6+1], r0 = l0[x*6+2]; - int b1 = l0[x*6+3], g1 = l0[x*6+4], r1 = l0[x*6+5]; - int b2 = l1[x*6+0], g2 = l1[x*6+1], r2 = l1[x*6+2]; - int b3 = l1[x*6+3], g3 = l1[x*6+4], r3 = l1[x*6+5]; - dstY[y*yStride + x*2] = static_cast((66*r0+129*g0+25*b0+128)>>8)+16; - dstY[y*yStride + x*2+1] = static_cast((66*r1+129*g1+25*b1+128)>>8)+16; - dstY[(y+1)*yStride + x*2] = static_cast((66*r2+129*g2+25*b2+128)>>8)+16; - dstY[(y+1)*yStride + x*2+1] = static_cast((66*r3+129*g3+25*b3+128)>>8)+16; - int bA=(b0+b1+b2+b3)/4, rA=(r0+r1+r2+r3)/4, gA=(g0+g1+g2+g3)/4; - dstUV[(y/2)*uvStride + x*2] = static_cast((-38*rA-74*gA+112*bA+128)>>8)+128; - dstUV[(y/2)*uvStride + x*2+1] = static_cast((112*rA-94*gA-18*bA+128)>>8)+128; - } - } - } else if (frame.pixelFormat == PixelFormat::NV12 || frame.pixelFormat == PixelFormat::NV12f) { - for (int y = 0; y < h; y++) { - memcpy(yBuf.data() + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); - } - for (int y = 0; y < h2; y++) { - memcpy(uvBuf.data() + y * uvStride, frame.data[1] + y * frame.stride[1], static_cast(w2) * 2); - } - } else { - CCAP_LOG_E("Unsupported pixel format for writer on Windows: %d\n", - static_cast(frame.pixelFormat)); - return false; - } + m_sinkWriter = pWriter; + m_streamIndex = streamIndex; return true; } IMFSinkWriter* m_sinkWriter; DWORD m_streamIndex; bool m_mfInitialized; - std::atomic m_isOpened{false}; - std::atomic m_frameCount{0}; + std::atomic m_isOpened{ false }; + std::atomic m_frameCount{ 0 }; }; +VideoWriter::Impl* createVideoWriterImpl() { + return new WriterWindows(); +} + } // namespace ccap #endif // _WIN32 diff --git a/tests/test_video_writer.cpp b/tests/test_video_writer.cpp index bd86c88a..ae29adb7 100644 --- a/tests/test_video_writer.cpp +++ b/tests/test_video_writer.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace fs = std::filesystem; @@ -56,11 +57,12 @@ class VideoWriterTest : public ::testing::Test { } void TearDown() override { - // Clean up any test output files - for (const auto& entry : fs::directory_iterator(fs::temp_directory_path())) { + // Clean up any test output files (best-effort, ignore errors) + std::error_code ec; + for (const auto& entry : fs::directory_iterator(fs::temp_directory_path(), ec)) { std::string filename = entry.path().filename().string(); if (filename.find("ccap_writer_test_") == 0) { - fs::remove(entry.path()); + fs::remove(entry.path(), ec); } } } @@ -76,10 +78,11 @@ class VideoWriterCTest : public ::testing::Test { } void TearDown() override { - for (const auto& entry : fs::directory_iterator(fs::temp_directory_path())) { + std::error_code ec; + for (const auto& entry : fs::directory_iterator(fs::temp_directory_path(), ec)) { std::string filename = entry.path().filename().string(); if (filename.find("ccap_writer_test_") == 0) { - fs::remove(entry.path()); + fs::remove(entry.path(), ec); } } } @@ -199,6 +202,7 @@ TEST_F(VideoWriterTest, WriteFramesAndValidateFile) { EXPECT_LT(fileSize, 50 * 1024 * 1024); // less than 50MB // Verify file can be opened for playback +#ifdef CCAP_ENABLE_FILE_PLAYBACK ccap::Provider provider; EXPECT_TRUE(provider.open(outputPath.string())); auto framePtr = provider.grab(5000); @@ -208,6 +212,7 @@ TEST_F(VideoWriterTest, WriteFramesAndValidateFile) { EXPECT_EQ(framePtr->height, 240); } provider.close(); +#endif } TEST_F(VideoWriterTest, WriteFramesWithMovContainer) { @@ -229,15 +234,13 @@ TEST_F(VideoWriterTest, WriteFramesWithMovContainer) { int stride = w * 3; std::vector frameData = createBgrFrame(w, h, stride); - ccap::VideoFrame frame; + ccap::VideoFrame frame{}; frame.data[0] = frameData.data(); frame.stride[0] = static_cast(stride); frame.pixelFormat = ccap::PixelFormat::BGR24; frame.width = static_cast(w); frame.height = static_cast(h); frame.sizeInBytes = static_cast(stride * h); - frame.timestamp = 0; - frame.frameIndex = 0; frame.orientation = ccap::FrameOrientation::Default; // Write 10 frames @@ -283,7 +286,7 @@ TEST_F(VideoWriterTest, WriteAfterCloseFails) { writer.close(); // Writing after close should fail - ccap::VideoFrame frame; + ccap::VideoFrame frame{}; frame.data[0] = nullptr; frame.pixelFormat = ccap::PixelFormat::BGR24; frame.width = 320; @@ -357,19 +360,13 @@ TEST_F(VideoWriterCTest, OpenAndWriteFrames) { int stride = w * 3; std::vector frameData = createBgrFrame(w, h, stride); - CcapVideoFrameInfo frameInfo; + CcapVideoFrameInfo frameInfo{}; frameInfo.data[0] = frameData.data(); frameInfo.stride[0] = static_cast(stride); - frameInfo.data[1] = nullptr; - frameInfo.stride[1] = 0; - frameInfo.data[2] = nullptr; - frameInfo.stride[2] = 0; frameInfo.pixelFormat = CCAP_PIXEL_FORMAT_BGR24; frameInfo.width = static_cast(w); frameInfo.height = static_cast(h); frameInfo.sizeInBytes = static_cast(stride * h); - frameInfo.timestamp = 0; - frameInfo.frameIndex = 0; frameInfo.orientation = CCAP_FRAME_ORIENTATION_TOP_TO_BOTTOM; // Write 15 frames From ba6a43ac90d985f6af8424f3e46a971e4de156aa Mon Sep 17 00:00:00 2001 From: wy Date: Sat, 16 May 2026 19:54:23 +0800 Subject: [PATCH 03/10] fix: remove codecapi.h include to fix MinGW build MinGW's codecapi.h has UUID template issues that cause compilation errors. The header was unused - no codec API types are needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ccap_writer_windows.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ccap_writer_windows.cpp b/src/ccap_writer_windows.cpp index 0e6775d8..cf335498 100644 --- a/src/ccap_writer_windows.cpp +++ b/src/ccap_writer_windows.cpp @@ -14,7 +14,6 @@ #define NOMINMAX #endif #include -#include #include #include #include From 4feb1da78f70108c87fc72cb1fbabdb92e10b853 Mon Sep 17 00:00:00 2001 From: wy Date: Sat, 16 May 2026 20:33:24 +0800 Subject: [PATCH 04/10] feat: add camera-to-video recording demo and CLI --record support - examples/desktop/6-record_video.cpp: new demo that opens camera and records ~5 seconds of frames to an MP4 file using ccap::VideoWriter. Guarded with #ifdef CCAP_ENABLE_VIDEO_WRITER for non-supported platforms. - cli: add --record option to record camera frames to a video file. Integrated into captureFrames() guarded by #ifdef CCAP_ENABLE_VIDEO_WRITER. Warns if used without -c/--count or --timeout (would run indefinitely). Warns if used with --video mode (not supported). - .vscode/tasks.json: add 'Run ccap CLI --record camera (Debug/Release)' tasks that record 5 seconds from the default camera to camera_capture.mp4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 56 +++++++++++++ cli/args_parser.cpp | 12 +++ cli/args_parser.h | 3 + cli/ccap_cli.cpp | 6 ++ cli/ccap_cli_utils.cpp | 50 +++++++++++ examples/desktop/6-record_video.cpp | 123 ++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+) create mode 100644 examples/desktop/6-record_video.cpp diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 802a56c3..da1ea8b0 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1547,6 +1547,62 @@ }, "problemMatcher": "$msCompile" } + }, + { + "label": "Run ccap CLI --record camera (Debug)", + "type": "shell", + "command": "bash", + "args": [ + "-l", + "-c", + "( if [[ $(pwd) =~ ^/mnt ]]; then ./ccap.exe --record ./camera_capture.mp4 --timeout 5; else ./ccap --record ./camera_capture.mp4 --timeout 5; fi )" + ], + "options": { + "cwd": "${workspaceFolder}/build/Debug" + }, + "group": "build", + "problemMatcher": "$gcc", + "dependsOn": [ + "Config: Enable CLI Tool", + "Build Project (Debug)" + ], + "dependsOrder": "sequence", + "windows": { + "command": ".\\ccap.exe", + "args": ["--record", ".\\camera_capture.mp4", "--timeout", "5"], + "options": { + "cwd": "${workspaceFolder}/build/Debug" + }, + "problemMatcher": "$msCompile" + } + }, + { + "label": "Run ccap CLI --record camera (Release)", + "type": "shell", + "command": "bash", + "args": [ + "-l", + "-c", + "( if [[ $(pwd) =~ ^/mnt ]]; then ./ccap.exe --record ./camera_capture.mp4 --timeout 5; else ./ccap --record ./camera_capture.mp4 --timeout 5; fi )" + ], + "options": { + "cwd": "${workspaceFolder}/build/Release" + }, + "group": "build", + "problemMatcher": "$gcc", + "dependsOn": [ + "Config: Enable CLI Tool", + "Build Project (Release)" + ], + "dependsOrder": "sequence", + "windows": { + "command": ".\\ccap.exe", + "args": ["--record", ".\\camera_capture.mp4", "--timeout", "5"], + "options": { + "cwd": "${workspaceFolder}/build/Release" + }, + "problemMatcher": "$msCompile" + } } ] } \ No newline at end of file diff --git a/cli/args_parser.cpp b/cli/args_parser.cpp index b138d14f..b2df486b 100644 --- a/cli/args_parser.cpp +++ b/cli/args_parser.cpp @@ -172,6 +172,14 @@ void printUsage(const char* programName) { #endif << "\n"; +#ifdef CCAP_ENABLE_VIDEO_WRITER + std::cout << "Video recording options (camera mode only):\n" + << " --record file record camera frames to a video file (e.g., output.mp4)\n" + << " Use -c to limit the number of frames, or --timeout for duration\n" + << " Supported formats: .mp4, .mov\n" + << "\n"; +#endif + #ifdef CCAP_CLI_WITH_GLFW std::cout << "Preview options:\n" << " -p, --preview enable window preview\n" @@ -391,6 +399,10 @@ CLIOptions parseArgs(int argc, char* argv[]) { if (i + 1 < argc) { opts.videoFilePath = argv[++i]; } + } else if (arg == "--record") { + if (i + 1 < argc) { + opts.recordVideoPath = argv[++i]; + } } else if (arg == "-w" || arg == "--width") { if (i + 1 < argc) { opts.width = std::atoi(argv[++i]); diff --git a/cli/args_parser.h b/cli/args_parser.h index 0ff986d0..fa6e270b 100644 --- a/cli/args_parser.h +++ b/cli/args_parser.h @@ -84,6 +84,9 @@ struct CLIOptions { double playbackSpeed = 0.0; // 0.0 = no frame rate control, 1.0 = normal speed bool playbackSpeedSpecified = false; + // Video recording settings + std::string recordVideoPath; ///< Output video file path for --record (camera mode only) + // Conversion settings std::string convertInput; std::string convertOutput; diff --git a/cli/ccap_cli.cpp b/cli/ccap_cli.cpp index eb06c613..9124a53d 100644 --- a/cli/ccap_cli.cpp +++ b/cli/ccap_cli.cpp @@ -82,6 +82,12 @@ int main(int argc, char* argv[]) { return 1; } + // --record without a frame limit will run indefinitely + if (!opts.recordVideoPath.empty() && !opts.captureCountSpecified && opts.timeoutSeconds == 0) { + std::cerr << "Warning: --record specified without -c/--count or --timeout. " + "Use Ctrl+C to stop recording." << std::endl; + } + // Set log level based on options if (opts.verbose) { ccap::setLogLevel(ccap::LogLevel::Verbose); diff --git a/cli/ccap_cli_utils.cpp b/cli/ccap_cli_utils.cpp index 13558037..90bf3d1c 100644 --- a/cli/ccap_cli_utils.cpp +++ b/cli/ccap_cli_utils.cpp @@ -10,6 +10,10 @@ #include #include +#ifdef CCAP_ENABLE_VIDEO_WRITER +#include +#endif + #include #include #include @@ -663,6 +667,34 @@ int captureFrames(const CLIOptions& opts) { return 1; } + // Setup video writer for --record (camera mode only) +#ifdef CCAP_ENABLE_VIDEO_WRITER + std::unique_ptr videoWriter; + if (!opts.recordVideoPath.empty()) { + if (isVideoMode) { + std::cerr << "Warning: --record is not supported in video file mode. Ignoring." << std::endl; + } else { + int camWidth = static_cast(provider.get(ccap::PropertyName::Width)); + int camHeight = static_cast(provider.get(ccap::PropertyName::Height)); + double camFps = provider.get(ccap::PropertyName::FrameRate); + + ccap::WriterConfig writerConfig; + writerConfig.width = static_cast(camWidth); + writerConfig.height = static_cast(camHeight); + writerConfig.frameRate = camFps > 0.0 ? camFps : 30.0; + + videoWriter = std::make_unique(); + if (!videoWriter->open(opts.recordVideoPath, writerConfig)) { + std::cerr << "Failed to open video writer for: " << opts.recordVideoPath << std::endl; + return 1; + } + if (ccap::infoLogEnabled()) { + std::cout << "Recording to: " << opts.recordVideoPath << std::endl; + } + } + } +#endif + // Create output directory if saving frames bool shouldSave = opts.saveFrames && !opts.outputDir.empty(); if (shouldSave) { @@ -724,6 +756,15 @@ int captureFrames(const CLIOptions& opts) { std::cout << "Frame " << frame->frameIndex << ": " << frame->width << "x" << frame->height << " format=" << ccap::pixelFormatToString(frame->pixelFormat) << std::endl; + // Write frame to video file if recording +#ifdef CCAP_ENABLE_VIDEO_WRITER + if (videoWriter && videoWriter->isOpened()) { + if (!videoWriter->writeFrame(*frame)) { + std::cerr << "Warning: Failed to write frame " << frame->frameIndex << " to video." << std::endl; + } + } +#endif + // Save frame if enabled if (shouldSave) { // Generate output filename @@ -747,6 +788,15 @@ int captureFrames(const CLIOptions& opts) { std::cout << "Captured " << capturedCount << " frame(s)." << std::endl; +#ifdef CCAP_ENABLE_VIDEO_WRITER + if (videoWriter && videoWriter->isOpened()) { + videoWriter->close(); + if (ccap::infoLogEnabled()) { + std::cout << "Video saved to: " << opts.recordVideoPath << std::endl; + } + } +#endif + if (timeoutOccurred) { return opts.timeoutExitCode; } diff --git a/examples/desktop/6-record_video.cpp b/examples/desktop/6-record_video.cpp new file mode 100644 index 00000000..6fa6ca19 --- /dev/null +++ b/examples/desktop/6-record_video.cpp @@ -0,0 +1,123 @@ +/** + * @file 6-record_video.cpp + * @author wysaid (this@wysaid.org) + * @brief Example: open a camera and record frames to a video file. + * @date 2025-05 + * + * Usage: + * ./6-record_video [output_path.mp4] + * + * Records ~5 seconds (150 frames at 30 fps) from the first available camera + * and saves them to output_path.mp4 (default: camera_capture.mp4 next to the binary). + */ + +#include "utils/helper.h" + +#include +#include +#include +#include +#include + +#ifndef CCAP_ENABLE_VIDEO_WRITER + +int main() { + std::cerr << "[WARNING] Video writing is not supported on this platform.\n" + << "Rebuild with -DCCAP_ENABLE_VIDEO_WRITER=ON (requires Windows or macOS).\n"; + return 0; +} + +#else + +#include + +int main(int argc, char** argv) { + ExampleCommandLine commandLine{}; + initExampleCommandLine(&commandLine, argc, argv); + applyExampleCameraBackend(&commandLine); + + ccap::setLogLevel(ccap::LogLevel::Verbose); + + ccap::setErrorCallback([](ccap::ErrorCode errorCode, std::string_view description) { + std::cerr << "Error - Code: " << static_cast(errorCode) + << ", Description: " << description << "\n"; + }); + + // Determine output path + std::string outputPath; + if (commandLine.argc >= 2) { + outputPath = commandLine.argv[1]; + } else { + std::string exeDir = commandLine.argv[0]; + if (auto pos = exeDir.find_last_of("/\\"); pos != std::string::npos && exeDir[0] != '.') { + exeDir = exeDir.substr(0, pos); + } else { + exeDir = std::filesystem::current_path().string(); + } + outputPath = exeDir + "/camera_capture.mp4"; + } + + std::cout << "Output video: " << outputPath << "\n"; + + // Open camera + ccap::Provider cameraProvider; + cameraProvider.set(ccap::PropertyName::Width, 1280); + cameraProvider.set(ccap::PropertyName::Height, 720); + cameraProvider.set(ccap::PropertyName::FrameRate, 30.0); + + int deviceIndex = selectCamera(cameraProvider, &commandLine); + cameraProvider.open(deviceIndex, true); + + if (!cameraProvider.isStarted()) { + std::cerr << "Failed to start camera!\n"; + return -1; + } + + int realWidth = static_cast(cameraProvider.get(ccap::PropertyName::Width)); + int realHeight = static_cast(cameraProvider.get(ccap::PropertyName::Height)); + double realFps = cameraProvider.get(ccap::PropertyName::FrameRate); + + printf("Camera started: %dx%d @ %.2f fps\n", realWidth, realHeight, realFps); + + // Configure and open video writer + ccap::WriterConfig writerConfig; + writerConfig.width = static_cast(realWidth); + writerConfig.height = static_cast(realHeight); + writerConfig.frameRate = realFps > 0.0 ? realFps : 30.0; + + ccap::VideoWriter writer; + if (!writer.open(outputPath, writerConfig)) { + std::cerr << "Failed to open video writer!\n"; + return -1; + } + + // Record ~5 seconds + constexpr int kMaxFrames = 150; + int recorded = 0; + std::cout << "Recording " << kMaxFrames << " frames (~5 seconds)...\n"; + + while (recorded < kMaxFrames) { + auto frame = cameraProvider.grab(3000); + if (!frame) { + std::cerr << "Timeout waiting for camera frame.\n"; + break; + } + + if (!writer.writeFrame(*frame)) { + std::cerr << "Failed to write frame " << recorded << "\n"; + } + + if (++recorded % 30 == 0) { + printf(" Recorded %d/%d frames...\n", recorded, kMaxFrames); + } + } + + writer.close(); + cameraProvider.stop(); + cameraProvider.close(); + + printf("Done! %d frames saved to: %s\n", recorded, outputPath.c_str()); + return 0; +} + +#endif // CCAP_ENABLE_VIDEO_WRITER From 30eb121f707bcf6c182df20bc5f4e9c41783afc8 Mon Sep 17 00:00:00 2001 From: wy Date: Sat, 16 May 2026 21:42:20 +0800 Subject: [PATCH 05/10] chore: add Run 6-record_video tasks for Debug and Release Co-Authored-By: Claude Opus 4.7 --- .vscode/tasks.json | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index da1ea8b0..c25ba8fc 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1136,6 +1136,58 @@ "problemMatcher": "$msCompile" } }, + { + "label": "Run 6-record_video (Debug)", + "type": "shell", + "command": "bash", + "args": [ + "-l", + "-c", + "( if [[ $(pwd) =~ ^/mnt ]]; then ./6-record_video.exe; else ./6-record_video; fi )" + ], + "options": { + "cwd": "${workspaceFolder}/build/Debug" + }, + "group": "build", + "problemMatcher": "$gcc", + "dependsOn": [ + "Build Project (Debug)" + ], + "windows": { + "command": ".\\6-record_video.exe", + "args": [], + "options": { + "cwd": "${workspaceFolder}/build/Debug" + }, + "problemMatcher": "$msCompile" + } + }, + { + "label": "Run 6-record_video (Release)", + "type": "shell", + "command": "bash", + "args": [ + "-l", + "-c", + "( if [[ $(pwd) =~ ^/mnt ]]; then ./6-record_video.exe; else ./6-record_video; fi )" + ], + "options": { + "cwd": "${workspaceFolder}/build/Release" + }, + "group": "build", + "problemMatcher": "$gcc", + "dependsOn": [ + "Build Project (Release)" + ], + "windows": { + "command": ".\\6-record_video.exe", + "args": [], + "options": { + "cwd": "${workspaceFolder}/build/Release" + }, + "problemMatcher": "$msCompile" + } + }, { "label": "Run ccap CLI --help (Debug)", "type": "shell", From d5eb708fb2cc9bc70270419a2ba3a6f4be6169b8 Mon Sep 17 00:00:00 2001 From: wy Date: Sun, 17 May 2026 01:20:52 +0800 Subject: [PATCH 06/10] fix: use steady_clock for video writer timestamps to avoid first-frame pause Camera hardware PTS can have large startup gaps, causing the first frame to display for ~1 second. Use std::chrono::steady_clock wall-clock time instead of camera timestamps to generate PTS, ensuring frame intervals reflect real grab timing. Also add transcode duration verification tests. Co-Authored-By: Claude Opus 4.7 --- examples/desktop/6-record_video.cpp | 11 ++- tests/test_video_writer.cpp | 147 +++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/examples/desktop/6-record_video.cpp b/examples/desktop/6-record_video.cpp index 6fa6ca19..d8ad3f66 100644 --- a/examples/desktop/6-record_video.cpp +++ b/examples/desktop/6-record_video.cpp @@ -30,6 +30,7 @@ int main() { #else #include +#include int main(int argc, char** argv) { ExampleCommandLine commandLine{}; @@ -94,6 +95,8 @@ int main(int argc, char** argv) { // Record ~5 seconds constexpr int kMaxFrames = 150; int recorded = 0; + using Clock = std::chrono::steady_clock; + Clock::time_point recordStart; std::cout << "Recording " << kMaxFrames << " frames (~5 seconds)...\n"; while (recorded < kMaxFrames) { @@ -103,7 +106,13 @@ int main(int argc, char** argv) { break; } - if (!writer.writeFrame(*frame)) { + if (recorded == 0) { + recordStart = Clock::now(); + } + auto elapsedNs = std::chrono::duration_cast(Clock::now() - recordStart); + uint64_t timestampNs = static_cast(elapsedNs.count()); + + if (!writer.writeFrame(*frame, timestampNs)) { std::cerr << "Failed to write frame " << recorded << "\n"; } diff --git a/tests/test_video_writer.cpp b/tests/test_video_writer.cpp index ae29adb7..aa998606 100644 --- a/tests/test_video_writer.cpp +++ b/tests/test_video_writer.cpp @@ -188,7 +188,7 @@ TEST_F(VideoWriterTest, WriteFramesAndValidateFile) { for (int i = 0; i < 30; i++) { frame.timestamp = static_cast(i) * 33333333; // ~30fps in ns frame.frameIndex = static_cast(i); - bool writeResult = writer.writeFrame(frame); + bool writeResult = writer.writeFrame(frame, frame.timestamp); EXPECT_TRUE(writeResult); } @@ -389,6 +389,151 @@ TEST_F(VideoWriterCTest, OpenAndWriteFrames) { EXPECT_GT(fs::file_size(outputPath), 0); } +// Helper: locate the built-in test video by walking up from CWD to find the project root +static fs::path findTestVideo() { + fs::path projectRoot = fs::current_path(); + while (projectRoot.has_parent_path()) { + if (fs::exists(projectRoot / "CMakeLists.txt") && fs::exists(projectRoot / "tests")) { + break; + } + projectRoot = projectRoot.parent_path(); + } + return projectRoot / "tests" / "test-data" / "test.mp4"; +} + +// ---- Transcode Test: verify timestamps survive a read→write→read round-trip ---- + +TEST_F(VideoWriterTest, TranscodePreservesDuration) { +#ifdef CCAP_ENABLE_FILE_PLAYBACK + fs::path inputPath = findTestVideo(); + if (!fs::exists(inputPath)) { + GTEST_SKIP() << "test.mp4 not found at " << inputPath; + } + + // 1. Read source video metadata + ccap::Provider reader; + ASSERT_TRUE(reader.open(inputPath.string())) << "Failed to open source video"; + + double srcDuration = reader.get(ccap::PropertyName::Duration); + int srcWidth = static_cast(reader.get(ccap::PropertyName::Width)); + int srcHeight = static_cast(reader.get(ccap::PropertyName::Height)); + double srcFps = reader.get(ccap::PropertyName::FrameRate); + ASSERT_GT(srcDuration, 0.0) << "Source video duration should be positive"; + ASSERT_GT(srcWidth, 0); + ASSERT_GT(srcHeight, 0); + ASSERT_GT(srcFps, 0.0); + + // 2. Read all frames and write them to a new file, forwarding timestamps + fs::path outputPath = getTestOutputPath("transcode_duration"); + + ccap::WriterConfig writerConfig; + writerConfig.width = static_cast(srcWidth); + writerConfig.height = static_cast(srcHeight); + writerConfig.frameRate = srcFps; + writerConfig.bitRate = 2'000'000; + + ccap::VideoWriter writer; + ASSERT_TRUE(writer.open(outputPath.string(), writerConfig)) << "Failed to open writer"; + + int frameCount = 0; + uint64_t firstTimestamp = 0; + while (true) { + auto frame = reader.grab(5000); + if (!frame) break; + + if (frameCount == 0) { + firstTimestamp = frame->timestamp; + } + uint64_t relativeTs = frame->timestamp - firstTimestamp; + + ASSERT_TRUE(writer.writeFrame(*frame, relativeTs)) + << "Failed to write frame " << frameCount; + frameCount++; + } + + writer.close(); + reader.close(); + + ASSERT_GT(frameCount, 0) << "No frames read from source video"; + + // 3. Open the output file and verify its duration matches the source + ccap::Provider outReader; + ASSERT_TRUE(outReader.open(outputPath.string())) << "Failed to open output video for verification"; + + double outDuration = outReader.get(ccap::PropertyName::Duration); + outReader.close(); + + // Allow 10% tolerance (encode/decode and container overhead may cause slight differences) + double ratio = outDuration / srcDuration; + EXPECT_GT(ratio, 0.9) << "Output duration (" << outDuration + << "s) is too short vs source (" << srcDuration << "s)"; + EXPECT_LT(ratio, 1.1) << "Output duration (" << outDuration + << "s) is too long vs source (" << srcDuration << "s)"; +#else + GTEST_SKIP() << "File playback not enabled, cannot run transcode test"; +#endif +} + +// ---- Transcode test with auto-timestamp (should produce shorter video if camera is slower) ---- + +TEST_F(VideoWriterTest, TranscodeWithAutoTimestampProducesDifferentDuration) { +#ifdef CCAP_ENABLE_FILE_PLAYBACK + fs::path inputPath = findTestVideo(); + if (!fs::exists(inputPath)) { + GTEST_SKIP() << "test.mp4 not found at " << inputPath; + } + + ccap::Provider reader; + ASSERT_TRUE(reader.open(inputPath.string())); + + double srcDuration = reader.get(ccap::PropertyName::Duration); + int srcWidth = static_cast(reader.get(ccap::PropertyName::Width)); + int srcHeight = static_cast(reader.get(ccap::PropertyName::Height)); + double srcFps = reader.get(ccap::PropertyName::FrameRate); + ASSERT_GT(srcDuration, 0.0); + + // Write with auto-timestamp (timestampNs = 0) using a HIGHER frame rate than source + // This simulates the camera-slower-than-configured scenario + fs::path outputPath = getTestOutputPath("transcode_auto_ts"); + + ccap::WriterConfig writerConfig; + writerConfig.width = static_cast(srcWidth); + writerConfig.height = static_cast(srcHeight); + writerConfig.frameRate = srcFps * 2; // Claim 2x the actual fps + writerConfig.bitRate = 2'000'000; + + ccap::VideoWriter writer; + ASSERT_TRUE(writer.open(outputPath.string(), writerConfig)); + + int frameCount = 0; + while (true) { + auto frame = reader.grab(5000); + if (!frame) break; + // Deliberately pass timestampNs = 0 (auto-increment mode) + ASSERT_TRUE(writer.writeFrame(*frame, 0)); + frameCount++; + } + + writer.close(); + reader.close(); + + ASSERT_GT(frameCount, 0); + + // Verify output is approximately half the source duration (2x claimed fps, same frames) + ccap::Provider outReader; + ASSERT_TRUE(outReader.open(outputPath.string())); + + double outDuration = outReader.get(ccap::PropertyName::Duration); + outReader.close(); + + // With 2x fps claimed and auto-timestamp, video duration should be ~half the source + double ratio = outDuration / srcDuration; + EXPECT_LT(ratio, 0.7) << "Auto-timestamp with 2x fps should produce shorter video, got ratio=" << ratio; +#else + GTEST_SKIP() << "File playback not enabled"; +#endif +} + TEST_F(VideoWriterCTest, InvalidOpenParams) { CcapVideoWriter* writer = ccap_video_writer_create(); ASSERT_NE(writer, nullptr); From 2bb278b850a0eb09aeaf979b4b9b9ef4d0648aa2 Mon Sep 17 00:00:00 2001 From: "wangyang (wysaid)" Date: Sun, 17 May 2026 02:54:33 +0800 Subject: [PATCH 07/10] fix: respect VideoFrame::orientation in shared video writer NV12 conversion - Shared convertFrameToNv12 helper now honors frame.orientation (TopToBottom/BottomToTop) - DirectShow commonly yields BottomToTop RGB frames; writer was ignoring orientation and producing vertically flipped video - Added comprehensive regression tests: - SharedNv12ConversionRespectsBottomToTopOrientation (unit test for conversion helper) - BottomToTopFramesRoundTripUpright (C++ API end-to-end test) - BottomToTopFramesRoundTripUpright (C API end-to-end test) - Fixed Windows writer close() to clear configuration state (fixes GetPropertiesAfterOpen test) - Validated with live Windows camera capture: recorded video now matches original frame orientation Files changed: - src/ccap_writer_imp.h: orientation-aware row indexing for all frame formats (BGR24, BGRA32, NV12, I420) - src/ccap_writer_windows.cpp: reset config on close() - tests/test_video_writer.cpp: new orientation regression tests - tests/CMakeLists.txt: expose src headers to writer tests --- src/ccap_writer_imp.h | 33 +++-- src/ccap_writer_windows.cpp | 4 + tests/CMakeLists.txt | 6 + tests/test_video_writer.cpp | 270 ++++++++++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+), 10 deletions(-) diff --git a/src/ccap_writer_imp.h b/src/ccap_writer_imp.h index 13e9f643..e0c35400 100644 --- a/src/ccap_writer_imp.h +++ b/src/ccap_writer_imp.h @@ -38,15 +38,20 @@ VideoWriter::Impl* createVideoWriterImpl(); // ---- Shared NV12 conversion helpers (used by both platform implementations) ---- +inline int orientedRowIndex(FrameOrientation orientation, int row, int height) { + return orientation == FrameOrientation::BottomToTop ? (height - 1 - row) : row; +} + inline void bgrToNv12(const uint8_t* src, int srcStride, uint8_t* dstY, int dstYStride, uint8_t* dstUV, int dstUVStride, - int width, int height, int bytesPerPixel) { + int width, int height, int bytesPerPixel, + FrameOrientation orientation) { // bytesPerPixel: 3 for BGR24, 4 for BGRA32 const int w2 = width / 2; for (int y = 0; y < height; y += 2) { - const uint8_t* line0 = src + y * srcStride; - const uint8_t* line1 = (y + 1 < height) ? src + (y + 1) * srcStride : line0; + const uint8_t* line0 = src + orientedRowIndex(orientation, y, height) * srcStride; + const uint8_t* line1 = (y + 1 < height) ? src + orientedRowIndex(orientation, y + 1, height) * srcStride : line0; for (int x = 0; x < w2; x++) { const int off = x * 2 * bytesPerPixel; int b0 = line0[off], g0 = line0[off + 1], r0 = line0[off + 2]; @@ -73,6 +78,7 @@ inline bool convertFrameToNv12(const VideoFrame& frame, const int h = static_cast(frame.height); const int w2 = w / 2; const int h2 = h / 2; + const FrameOrientation orientation = frame.orientation; yStride = static_cast(w); uvStride = static_cast(w2 * 2); @@ -83,19 +89,26 @@ inline bool convertFrameToNv12(const VideoFrame& frame, case PixelFormat::NV12: case PixelFormat::NV12f: for (int y = 0; y < h; y++) - std::memcpy(yBuf.data() + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); + std::memcpy(yBuf.data() + y * yStride, + frame.data[0] + orientedRowIndex(orientation, y, h) * frame.stride[0], + static_cast(w)); for (int y = 0; y < h2; y++) - std::memcpy(uvBuf.data() + y * uvStride, frame.data[1] + y * frame.stride[1], static_cast(w2) * 2); + std::memcpy(uvBuf.data() + y * uvStride, + frame.data[1] + orientedRowIndex(orientation, y, h2) * frame.stride[1], + static_cast(w2) * 2); return true; case PixelFormat::I420: case PixelFormat::I420f: for (int y = 0; y < h; y++) - std::memcpy(yBuf.data() + y * yStride, frame.data[0] + y * frame.stride[0], static_cast(w)); + std::memcpy(yBuf.data() + y * yStride, + frame.data[0] + orientedRowIndex(orientation, y, h) * frame.stride[0], + static_cast(w)); for (int y = 0; y < h2; y++) { + const int srcRow = orientedRowIndex(orientation, y, h2); for (int x = 0; x < w2; x++) { - uvBuf[y * uvStride + x * 2] = frame.data[1][y * frame.stride[1] + x]; - uvBuf[y * uvStride + x * 2 + 1] = frame.data[2][y * frame.stride[2] + x]; + uvBuf[y * uvStride + x * 2] = frame.data[1][srcRow * frame.stride[1] + x]; + uvBuf[y * uvStride + x * 2 + 1] = frame.data[2][srcRow * frame.stride[2] + x]; } } return true; @@ -103,13 +116,13 @@ inline bool convertFrameToNv12(const VideoFrame& frame, case PixelFormat::BGR24: bgrToNv12(frame.data[0], static_cast(frame.stride[0]), yBuf.data(), static_cast(yStride), - uvBuf.data(), static_cast(uvStride), w, h, 3); + uvBuf.data(), static_cast(uvStride), w, h, 3, orientation); return true; case PixelFormat::BGRA32: bgrToNv12(frame.data[0], static_cast(frame.stride[0]), yBuf.data(), static_cast(yStride), - uvBuf.data(), static_cast(uvStride), w, h, 4); + uvBuf.data(), static_cast(uvStride), w, h, 4, orientation); return true; default: diff --git a/src/ccap_writer_windows.cpp b/src/ccap_writer_windows.cpp index cf335498..ded5157d 100644 --- a/src/ccap_writer_windows.cpp +++ b/src/ccap_writer_windows.cpp @@ -108,6 +108,10 @@ class WriterWindows : public VideoWriter::Impl { m_sinkWriter->Release(); m_sinkWriter = nullptr; } + + m_streamIndex = 0; + m_frameCount = 0; + std::memset(&m_config, 0, sizeof(m_config)); } bool isOpened() const override { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3b2b9bb7..98ad81ab 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -451,6 +451,12 @@ if (CCAP_ENABLE_VIDEO_WRITER AND (APPLE OR WIN32)) gmock ) + target_include_directories( + ccap_video_writer_test + PRIVATE + ${CMAKE_SOURCE_DIR}/src + ) + set_target_properties(ccap_video_writer_test PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON diff --git a/tests/test_video_writer.cpp b/tests/test_video_writer.cpp index aa998606..c9474785 100644 --- a/tests/test_video_writer.cpp +++ b/tests/test_video_writer.cpp @@ -7,8 +7,11 @@ */ #include +#include #include #include +#include "ccap_writer_imp.h" +#include #include #include #include @@ -16,12 +19,144 @@ #include #include #include +#include #include #include #include namespace fs = std::filesystem; +namespace { + +struct MeanBgr { + double b = 0.0; + double g = 0.0; + double r = 0.0; +}; + +std::vector createQuadrantBgrFrame(int w, int h, int stride) { + std::vector data(static_cast(stride) * h, 0); + for (int y = 0; y < h; ++y) { + uint8_t* row = data.data() + static_cast(y) * stride; + for (int x = 0; x < w; ++x) { + uint8_t* pixel = row + x * 3; + const bool isTop = y < h / 2; + const bool isLeft = x < w / 2; + if (isTop && isLeft) { + pixel[0] = 240; // B + pixel[1] = 32; // G + pixel[2] = 32; // R + } else if (isTop) { + pixel[0] = 32; + pixel[1] = 240; + pixel[2] = 32; + } else if (isLeft) { + pixel[0] = 32; + pixel[1] = 32; + pixel[2] = 240; + } else { + pixel[0] = 230; + pixel[1] = 230; + pixel[2] = 230; + } + } + } + return data; +} + +std::vector flipRows(const std::vector& src, int stride, int h) { + std::vector dst(src.size(), 0); + for (int y = 0; y < h; ++y) { + std::memcpy(dst.data() + static_cast(y) * stride, + src.data() + static_cast(h - 1 - y) * stride, + static_cast(stride)); + } + return dst; +} + +MeanBgr calculateLogicalRegionMean(const ccap::VideoFrame& frame, int x0, int y0, int regionWidth, int regionHeight) { + const bool hasAlpha = ccap::pixelFormatInclude(frame.pixelFormat, ccap::kPixelFormatAlphaColorBit); + const bool isBgrOrder = ccap::pixelFormatInclude(frame.pixelFormat, ccap::kPixelFormatBGRBit); + const int channels = hasAlpha ? 4 : 3; + MeanBgr mean{}; + const double samples = static_cast(regionWidth * regionHeight); + + for (int y = y0; y < y0 + regionHeight; ++y) { + const int logicalRow = frame.orientation == ccap::FrameOrientation::TopToBottom ? y : static_cast(frame.height) - 1 - y; + const uint8_t* row = frame.data[0] + static_cast(logicalRow) * frame.stride[0]; + for (int x = x0; x < x0 + regionWidth; ++x) { + const uint8_t* pixel = row + x * channels; + if (isBgrOrder) { + mean.b += pixel[0]; + mean.g += pixel[1]; + mean.r += pixel[2]; + } else { + mean.r += pixel[0]; + mean.g += pixel[1]; + mean.b += pixel[2]; + } + } + } + + mean.b /= samples; + mean.g /= samples; + mean.r /= samples; + return mean; +} + +std::string meanToString(const MeanBgr& mean) { + std::ostringstream stream; + stream << "(B=" << mean.b << ", G=" << mean.g << ", R=" << mean.r << ")"; + return stream.str(); +} + +void expectUprightQuadrantPattern(const ccap::VideoFrame& frame) { + ASSERT_TRUE(ccap::pixelFormatInclude(frame.pixelFormat, ccap::kPixelFormatRGBColorBit)) + << "Expected RGB output, got pixel format=" << static_cast(frame.pixelFormat); + + const int width = static_cast(frame.width); + const int height = static_cast(frame.height); + const int sampleWidth = std::max(8, width / 4); + const int sampleHeight = std::max(8, height / 4); + + const MeanBgr topLeft = calculateLogicalRegionMean(frame, width / 8, height / 8, sampleWidth, sampleHeight); + const MeanBgr topRight = calculateLogicalRegionMean(frame, width / 2 + width / 8, height / 8, sampleWidth, sampleHeight); + const MeanBgr bottomLeft = calculateLogicalRegionMean(frame, width / 8, height / 2 + height / 8, sampleWidth, sampleHeight); + const MeanBgr bottomRight = calculateLogicalRegionMean(frame, width / 2 + width / 8, height / 2 + height / 8, sampleWidth, sampleHeight); + + EXPECT_GT(topLeft.b, topLeft.g + 40.0) << meanToString(topLeft); + EXPECT_GT(topLeft.b, topLeft.r + 40.0) << meanToString(topLeft); + + EXPECT_GT(topRight.g, topRight.b + 40.0) << meanToString(topRight); + EXPECT_GT(topRight.g, topRight.r + 40.0) << meanToString(topRight); + + EXPECT_GT(bottomLeft.r, bottomLeft.b + 40.0) << meanToString(bottomLeft); + EXPECT_GT(bottomLeft.r, bottomLeft.g + 40.0) << meanToString(bottomLeft); + + const double whiteMin = std::min({ bottomRight.b, bottomRight.g, bottomRight.r }); + const double whiteMax = std::max({ bottomRight.b, bottomRight.g, bottomRight.r }); + EXPECT_GT(whiteMin, 170.0) << meanToString(bottomRight); + EXPECT_LT(whiteMax - whiteMin, 50.0) << meanToString(bottomRight); +} + +void initializeBgrFrame(ccap::VideoFrame& frame, uint8_t* data, int w, int h, int stride, ccap::FrameOrientation orientation) { + frame.data[0] = data; + frame.data[1] = nullptr; + frame.data[2] = nullptr; + frame.stride[0] = static_cast(stride); + frame.stride[1] = 0; + frame.stride[2] = 0; + frame.pixelFormat = ccap::PixelFormat::BGR24; + frame.width = static_cast(w); + frame.height = static_cast(h); + frame.sizeInBytes = static_cast(stride * h); + frame.timestamp = 0; + frame.frameIndex = 0; + frame.orientation = orientation; +} + +} // namespace + // Helper to check if video writer is supported on this platform bool isVideoWriterSupported() { #if (defined(__APPLE__) || defined(_WIN32)) && defined(CCAP_ENABLE_VIDEO_WRITER) @@ -215,6 +350,84 @@ TEST_F(VideoWriterTest, WriteFramesAndValidateFile) { #endif } +TEST_F(VideoWriterTest, SharedNv12ConversionRespectsBottomToTopOrientation) { + constexpr int w = 128; + constexpr int h = 96; + constexpr int stride = w * 3; + + std::vector topDown = createQuadrantBgrFrame(w, h, stride); + std::vector bottomUp = flipRows(topDown, stride, h); + + ccap::VideoFrame topFrame; + initializeBgrFrame(topFrame, topDown.data(), w, h, stride, ccap::FrameOrientation::TopToBottom); + + ccap::VideoFrame bottomFrame; + initializeBgrFrame(bottomFrame, bottomUp.data(), w, h, stride, ccap::FrameOrientation::BottomToTop); + + std::vector topY; + std::vector topUv; + uint32_t topYStride = 0; + uint32_t topUvStride = 0; + ASSERT_TRUE(ccap::convertFrameToNv12(topFrame, topY, topUv, topYStride, topUvStride)); + + std::vector bottomY; + std::vector bottomUv; + uint32_t bottomYStride = 0; + uint32_t bottomUvStride = 0; + ASSERT_TRUE(ccap::convertFrameToNv12(bottomFrame, bottomY, bottomUv, bottomYStride, bottomUvStride)); + + EXPECT_EQ(bottomYStride, topYStride); + EXPECT_EQ(bottomUvStride, topUvStride); + EXPECT_EQ(bottomY, topY); + EXPECT_EQ(bottomUv, topUv); +} + +TEST_F(VideoWriterTest, BottomToTopFramesRoundTripUpright) { +#ifdef CCAP_ENABLE_FILE_PLAYBACK + constexpr int w = 128; + constexpr int h = 96; + constexpr int stride = w * 3; + std::vector topDown = createQuadrantBgrFrame(w, h, stride); + std::vector bottomUp = flipRows(topDown, stride, h); + + ccap::WriterConfig config; + config.width = w; + config.height = h; + config.frameRate = 30.0; + config.bitRate = 8'000'000; + + fs::path outputPath = getTestOutputPath("bottom_to_top_cpp"); + ccap::VideoWriter writer; + ASSERT_TRUE(writer.open(outputPath.string(), config)); + + ccap::VideoFrame frame; + initializeBgrFrame(frame, bottomUp.data(), w, h, stride, ccap::FrameOrientation::BottomToTop); + + for (int index = 0; index < 12; ++index) { + frame.frameIndex = static_cast(index); + frame.timestamp = static_cast(index) * 33'333'333ULL; + ASSERT_TRUE(writer.writeFrame(frame, frame.timestamp)); + } + + writer.close(); + + ccap::Provider reader; + reader.set(ccap::PropertyName::PixelFormatOutput, ccap::PixelFormat::BGR24); + reader.set(ccap::PropertyName::FrameOrientation, ccap::FrameOrientation::TopToBottom); + ASSERT_TRUE(reader.open(outputPath.string())); + + auto decoded = reader.grab(5000); + ASSERT_NE(decoded, nullptr); + if (decoded) { + expectUprightQuadrantPattern(*decoded); + } + + reader.close(); +#else + GTEST_SKIP() << "File playback not enabled, cannot verify writer output orientation"; +#endif +} + TEST_F(VideoWriterTest, WriteFramesWithMovContainer) { ccap::VideoWriter writer; ccap::WriterConfig config; @@ -389,6 +602,63 @@ TEST_F(VideoWriterCTest, OpenAndWriteFrames) { EXPECT_GT(fs::file_size(outputPath), 0); } +TEST_F(VideoWriterCTest, BottomToTopFramesRoundTripUpright) { +#ifdef CCAP_ENABLE_FILE_PLAYBACK + constexpr int w = 128; + constexpr int h = 96; + constexpr int stride = w * 3; + std::vector topDown = createQuadrantBgrFrame(w, h, stride); + std::vector bottomUp = flipRows(topDown, stride, h); + + CcapVideoWriter* writer = ccap_video_writer_create(); + ASSERT_NE(writer, nullptr); + + CcapWriterConfig config{}; + config.codec = CCAP_VIDEO_CODEC_H264; + config.container = CCAP_VIDEO_FORMAT_MP4; + config.width = static_cast(w); + config.height = static_cast(h); + config.frameRate = 30.0; + config.bitRate = 8'000'000; + + fs::path outputPath = getTestOutputPath("bottom_to_top_c_api"); + ASSERT_TRUE(ccap_video_writer_open(writer, outputPath.string().c_str(), &config)); + + CcapVideoFrameInfo frameInfo{}; + frameInfo.data[0] = bottomUp.data(); + frameInfo.stride[0] = static_cast(stride); + frameInfo.pixelFormat = CCAP_PIXEL_FORMAT_BGR24; + frameInfo.width = static_cast(w); + frameInfo.height = static_cast(h); + frameInfo.sizeInBytes = static_cast(stride * h); + frameInfo.orientation = CCAP_FRAME_ORIENTATION_BOTTOM_TO_TOP; + + for (int index = 0; index < 12; ++index) { + frameInfo.frameIndex = static_cast(index); + const uint64_t timestamp = static_cast(index) * 33'333'333ULL; + ASSERT_TRUE(ccap_video_writer_write_frame(writer, &frameInfo, timestamp)); + } + + ccap_video_writer_close(writer); + ccap_video_writer_destroy(writer); + + ccap::Provider reader; + reader.set(ccap::PropertyName::PixelFormatOutput, ccap::PixelFormat::BGR24); + reader.set(ccap::PropertyName::FrameOrientation, ccap::FrameOrientation::TopToBottom); + ASSERT_TRUE(reader.open(outputPath.string())); + + auto decoded = reader.grab(5000); + ASSERT_NE(decoded, nullptr); + if (decoded) { + expectUprightQuadrantPattern(*decoded); + } + + reader.close(); +#else + GTEST_SKIP() << "File playback not enabled, cannot verify writer output orientation"; +#endif +} + // Helper: locate the built-in test video by walking up from CWD to find the project root static fs::path findTestVideo() { fs::path projectRoot = fs::current_path(); From 3a116a14e979a211e562c721f738ffe74c018092 Mon Sep 17 00:00:00 2001 From: "wangyang (wysaid)" Date: Sun, 17 May 2026 03:45:01 +0800 Subject: [PATCH 08/10] fix: finalize writer PR hardening and docs alignment --- BUILD_AND_INSTALL.md | 11 +++ README.md | 58 ++++++++++++ README.zh-CN.md | 58 ++++++++++++ cli/args_parser.cpp | 4 + cli/args_parser.h | 1 + cli/ccap_cli.cpp | 18 +--- cli/ccap_cli_utils.cpp | 156 +++++++++++++++---------------- docs/content/c-interface.md | 48 ++++++++++ docs/content/cli.md | 30 ++++++ docs/content/cmake-options.md | 20 ++++ docs/content/documentation.md | 35 +++++++ skills/ccap/SKILL.md | 8 +- src/ccap_file_reader_windows.cpp | 23 +++-- src/ccap_file_reader_windows.h | 3 + src/ccap_writer.cpp | 5 +- src/ccap_writer_c.cpp | 28 ++++-- tests/test_ccap_cli.cpp | 25 +++-- tests/test_cli_args_parser.cpp | 31 ++++++ 18 files changed, 447 insertions(+), 115 deletions(-) diff --git a/BUILD_AND_INSTALL.md b/BUILD_AND_INSTALL.md index cefaad33..5a66acd8 100644 --- a/BUILD_AND_INSTALL.md +++ b/BUILD_AND_INSTALL.md @@ -55,6 +55,7 @@ make install - `CCAP_BUILD_EXAMPLES`: Build example applications (default: ON for root project) - `CCAP_BUILD_TESTS`: Build unit tests (default: OFF) - `CCAP_NO_LOG`: Disable logging functionality (default: OFF) +- `CCAP_ENABLE_VIDEO_WRITER`: Enable video writer support (`ccap::VideoWriter`, C writer API, CLI `--record`) on Windows/macOS (default: ON) ### macOS Universal Binary Build @@ -205,6 +206,7 @@ build/universal/ # Contains x86_64 + arm64 universal binary - `CCAP_INSTALL`: Enable install target (default: ON) - `CCAP_BUILD_EXAMPLES`: Build examples (default: OFF when used as subproject) - `CCAP_BUILD_TESTS`: Build tests (default: OFF when used as subproject) +- `CCAP_ENABLE_VIDEO_WRITER`: Enable video writing support (Windows/macOS only, default: ON) ### Advanced Usage @@ -250,6 +252,15 @@ git clean -fdx install/ **Note**: Video file playback is currently supported on Windows and macOS only. Linux video playback support may be added in a future release. +### Video Writer Support Matrix + +- ✅ Windows: supported (`CCAP_ENABLE_VIDEO_WRITER=ON`) +- ✅ macOS: supported (`CCAP_ENABLE_VIDEO_WRITER=ON`) +- ❌ Linux: not supported +- ❌ iOS: not supported + +`CCAP_ENABLE_VIDEO_WRITER` is independent from `CCAP_ENABLE_FILE_PLAYBACK`. + ## Version Information Current version: 1.7.2 diff --git a/README.md b/README.md index 4622c0ab..b259922d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ A high-performance, lightweight cross-platform camera capture library with hardw - **Multiple Formats**: RGB, BGR, YUV (NV12/I420) with automatic conversion - **Dual Language APIs**: ✨ **Complete Pure C Interface** - Both modern C++ API and traditional C99 interface for various project integration and language bindings - **Video File Playback**: 🎬 Play video files (MP4, AVI, MOV, etc.) using the same API as camera capture - supports Windows and macOS +- **Video Writing / Recording**: 🎥 Write MP4/MOV files from camera frames via `ccap::VideoWriter`, `ccap_video_writer_*`, or CLI `--record` (Windows/macOS, `CCAP_ENABLE_VIDEO_WRITER=ON`) - **CLI Tool**: Ready-to-use command-line tool for quick camera operations and video processing - list devices, capture images, real-time preview, video playback ([Documentation](./docs/content/cli.md)) - **Production Ready**: Comprehensive test suite with 95%+ accuracy validation - **Virtual Camera Support**: Compatible with OBS Virtual Camera and similar tools through the default DirectShow path on Windows @@ -235,6 +236,8 @@ On Windows, camera capture now uses DirectShow by default. This keeps OBS Virtua For most Windows applications, staying in `auto` mode is recommended. ccap normalizes the public capture API, frame orientation handling, and output pixel-format conversion across both backends so callers usually do not need backend-specific code. +For video writing, backend selection is a separate axis: on Windows, `VideoWriter` uses Media Foundation's writer stack regardless of camera capture backend (`auto` / `dshow` / `msmf`). + - Pass `extraInfo` as `"auto"`, `"msmf"`, `"dshow"`, or `"backend="` in the C++/C constructors that accept it. - Set the environment variable `CCAP_WINDOWS_BACKEND=auto|msmf|dshow` to affect the whole process, including the CLI and Rust bindings. @@ -297,6 +300,9 @@ cmake --build . # Video preview with playback controls ./ccap -i video.mp4 --preview --speed 1.0 + +# Record camera stream to MP4 (Windows/macOS) +./ccap -d 0 --record ./camera_capture.mp4 --timeout 5 ``` **Key Features:** @@ -305,6 +311,7 @@ cmake --build . - 🎯 Capture single or multiple images - 👁️ Real-time preview window (with GLFW) - 🎬 Video file playback and frame extraction +- 🎥 Record camera stream to MP4/MOV (`--record`) - ⚙️ Configure resolution, format, and frame rate - 💾 Save images in various formats (JPEG, PNG, BMP, etc.) - ⏱️ Duration-based or count-based capture modes @@ -343,6 +350,7 @@ For complete CLI documentation, see [CLI Tool Guide](./docs/content/cli.md). | [3-capture_callback](./examples/desktop/3-capture_callback.cpp) / [3-capture_callback_c](./examples/desktop/3-capture_callback_c.c) | Callback-based capture | C++ / C | Desktop | | [4-example_with_glfw](./examples/desktop/4-example_with_glfw.cpp) / [4-example_with_glfw_c](./examples/desktop/4-example_with_glfw_c.c) | OpenGL rendering | C++ / C | Desktop | | [5-play_video](./examples/desktop/5-play_video.cpp) / [5-play_video_c](./examples/desktop/5-play_video_c.c) | Video file playback | C++ / C | Windows/macOS | +| [6-record_video](./examples/desktop/6-record_video.cpp) | Video recording with `VideoWriter` | C++ | Windows/macOS | | [iOS Demo](./examples/) | iOS application | Objective-C++ | iOS | ### Build and Run Examples @@ -460,6 +468,41 @@ enum class PixelFormat : uint32_t { }; ``` +### Video Writing (Windows/macOS) + +Video writing is available on Windows and macOS when `CCAP_ENABLE_VIDEO_WRITER=ON`. + +```cpp +#include +#include + +ccap::Provider provider; +ccap::VideoWriter writer; + +if (provider.open("", true)) { + ccap::WriterConfig cfg; + cfg.width = 1280; + cfg.height = 720; + cfg.frameRate = 30.0; + cfg.codec = ccap::VideoCodec::H264; + cfg.container = ccap::VideoFormat::MP4; + + if (writer.open("camera_record.mp4", cfg)) { + while (auto frame = provider.grab(3000)) { + // timestampNs == 0 means auto timestamp generation from frameRate. + writer.writeFrame(*frame, 0); + } + writer.close(); + } +} +``` + +Notes: + +- Writer input supports `NV12`, `I420`, `BGR24`, and `BGRA32`. +- `VideoFrame::orientation` is honored by the writer path (including `BottomToTop` frames common on Windows RGB capture). +- `CCAP_ENABLE_VIDEO_WRITER` is independent from `CCAP_ENABLE_FILE_PLAYBACK`. + ### Utility Functions ```cpp @@ -575,6 +618,20 @@ void ccap_provider_stop(CcapProvider* provider); bool ccap_provider_is_started(CcapProvider* provider); ``` +##### Video Writer API (C) + +```c +CcapVideoWriter* ccap_video_writer_create(void); +void ccap_video_writer_destroy(CcapVideoWriter* writer); +bool ccap_video_writer_open(CcapVideoWriter* writer, const char* filePath, const CcapWriterConfig* config); +bool ccap_video_writer_write_frame(CcapVideoWriter* writer, const CcapVideoFrameInfo* frameInfo, uint64_t timestampNs); +void ccap_video_writer_close(CcapVideoWriter* writer); +bool ccap_video_writer_is_opened(const CcapVideoWriter* writer); +CcapVideoCodec ccap_video_writer_actual_codec(const CcapVideoWriter* writer); +``` + +`timestampNs == 0` is treated as an auto-timestamp sentinel (derived from configured frame rate), not a literal timeline timestamp. + ##### Frame Capture and Processing ```c @@ -729,6 +786,7 @@ Comprehensive test suite with 50+ test cases covering all functionality: - Multi-backend testing (CPU, AVX2, Apple Accelerate, NEON) - Performance benchmarks and accuracy validation - 95%+ precision for pixel format conversions +- Video writer regression tests (`ccap_video_writer_test`) covering C++ and C APIs, codec fallback, MOV container, `BottomToTop` orientation, and transcode duration checks ```bash ./scripts/run_tests.sh diff --git a/README.zh-CN.md b/README.zh-CN.md index fe2158a4..a88f22d6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,6 +38,7 @@ - **多种格式**:RGB、BGR、YUV(NV12/I420)及自动转换 - **双语言接口**:✨ **新增完整纯 C 接口**,同时提供现代化 C++ API 和传统 C99 接口,支持各种项目集成和语言绑定 - **视频文件播放**:🎬 使用与相机相同的 API 播放视频文件(MP4、AVI、MOV 等)- 支持 Windows 和 macOS +- **视频写入 / 录制**:🎥 通过 `ccap::VideoWriter`、`ccap_video_writer_*` 或 CLI `--record` 将相机帧写入 MP4/MOV(Windows/macOS,需 `CCAP_ENABLE_VIDEO_WRITER=ON`) - **命令行工具**:开箱即用的命令行工具,快速实现相机操作和视频处理 - 列出设备、捕获图像、实时预览、视频播放([文档](./docs/content/cli.zh.md)) - **生产就绪**:完整测试套件,95%+ 精度验证 - **虚拟相机支持**:在 Windows 上通过默认 DirectShow 路径兼容 OBS Virtual Camera 等工具 @@ -198,6 +199,8 @@ Windows 上现在默认使用 DirectShow。这样做的主要原因是 DirectSho 对大多数 Windows 应用来说,建议直接使用 `auto` 模式。ccap 会在两个后端之上统一公开的采集 API、帧朝向处理和输出像素格式转换,所以调用方通常不需要编写后端分支逻辑。 +对于视频写入,后端选择是另一条独立维度:在 Windows 上,`VideoWriter` 固定使用 Media Foundation 写入链路,不受相机采集后端(`auto` / `dshow` / `msmf`)切换影响。 + - 在支持 `extraInfo` 的 C++ / C 构造接口中传入 `"auto"`、`"msmf"`、`"dshow"` 或 `"backend="`。 - 设置环境变量 `CCAP_WINDOWS_BACKEND=auto|msmf|dshow`,对整个进程生效,包括 CLI 和 Rust 绑定。 @@ -266,6 +269,9 @@ cmake --build . # 视频预览并控制播放 ./ccap -i video.mp4 --preview --speed 1.0 + +# 将相机流录制为 MP4(Windows/macOS) +./ccap -d 0 --record ./camera_capture.mp4 --timeout 5 ``` **主要功能:** @@ -273,6 +279,7 @@ cmake --build . - 🎯 捕获单张或多张图像 - 👁️ 实时预览窗口(需要 GLFW) - 🎬 视频文件播放和帧提取 +- 🎥 将相机流录制为 MP4/MOV(`--record`) - ⚙️ 配置分辨率、格式和帧率 - 💾 保存为多种图像格式(JPEG、PNG、BMP 等) - ⏱️ 基于时长或数量的捕获模式 @@ -310,6 +317,7 @@ cmake --build . | [3-capture_callback](./examples/desktop/3-capture_callback.cpp) / [3-capture_callback_c](./examples/desktop/3-capture_callback_c.c) | 回调式捕获 | C++ / C | 桌面端 | | [4-example_with_glfw](./examples/desktop/4-example_with_glfw.cpp) / [4-example_with_glfw_c](./examples/desktop/4-example_with_glfw_c.c) | OpenGL 渲染 | C++ / C | 桌面端 | | [5-play_video](./examples/desktop/5-play_video.cpp) / [5-play_video_c](./examples/desktop/5-play_video_c.c) | 视频文件播放 | C++ / C | Windows/macOS | +| [6-record_video](./examples/desktop/6-record_video.cpp) | 使用 `VideoWriter` 录制视频 | C++ | Windows/macOS | | [iOS Demo](./examples/) | iOS 应用程序 | Objective-C++ | iOS | ### 构建和运行示例 @@ -429,6 +437,41 @@ enum class PixelFormat : uint32_t { }; ``` +### 视频写入(Windows/macOS) + +当 `CCAP_ENABLE_VIDEO_WRITER=ON` 时,可在 Windows/macOS 使用视频写入能力。 + +```cpp +#include +#include + +ccap::Provider provider; +ccap::VideoWriter writer; + +if (provider.open("", true)) { + ccap::WriterConfig cfg; + cfg.width = 1280; + cfg.height = 720; + cfg.frameRate = 30.0; + cfg.codec = ccap::VideoCodec::H264; + cfg.container = ccap::VideoFormat::MP4; + + if (writer.open("camera_record.mp4", cfg)) { + while (auto frame = provider.grab(3000)) { + // timestampNs == 0 表示根据 frameRate 自动生成时间戳。 + writer.writeFrame(*frame, 0); + } + writer.close(); + } +} +``` + +说明: + +- 写入输入像素格式支持 `NV12`、`I420`、`BGR24`、`BGRA32`。 +- 写入链路会尊重 `VideoFrame::orientation`(包括 Windows RGB 常见的 `BottomToTop`)。 +- `CCAP_ENABLE_VIDEO_WRITER` 与 `CCAP_ENABLE_FILE_PLAYBACK` 为独立开关。 + ### 工具函数 ```cpp @@ -544,6 +587,20 @@ void ccap_provider_stop(CcapProvider* provider); bool ccap_provider_is_started(CcapProvider* provider); ``` +##### 视频写入 API(C) + +```c +CcapVideoWriter* ccap_video_writer_create(void); +void ccap_video_writer_destroy(CcapVideoWriter* writer); +bool ccap_video_writer_open(CcapVideoWriter* writer, const char* filePath, const CcapWriterConfig* config); +bool ccap_video_writer_write_frame(CcapVideoWriter* writer, const CcapVideoFrameInfo* frameInfo, uint64_t timestampNs); +void ccap_video_writer_close(CcapVideoWriter* writer); +bool ccap_video_writer_is_opened(const CcapVideoWriter* writer); +CcapVideoCodec ccap_video_writer_actual_codec(const CcapVideoWriter* writer); +``` + +`timestampNs == 0` 会被视为“自动时间戳哨兵值”(按配置帧率推导),而不是一个字面上的时间轴时间戳。 + ##### 帧捕获和处理 ```c @@ -650,6 +707,7 @@ C 接口的详细使用说明和示例请参见:[C 接口文档](./docs/conten - 多后端测试(CPU、AVX2、Apple Accelerate、NEON) - 性能基准测试和精度验证 - 像素格式转换 95%+ 精度 +- 视频写入回归测试(`ccap_video_writer_test`),覆盖 C++/C API、codec 回退、MOV 容器、`BottomToTop` 方向与转码时长校验 ```bash ./scripts/run_tests.sh diff --git a/cli/args_parser.cpp b/cli/args_parser.cpp index b2df486b..efa65ff2 100644 --- a/cli/args_parser.cpp +++ b/cli/args_parser.cpp @@ -352,6 +352,10 @@ CLIOptions parseArgs(int argc, char* argv[]) { opts.showVersion = true; } else if (arg == "--verbose") { opts.verbose = true; + opts.quiet = false; + } else if (arg == "-q" || arg == "--quiet") { + opts.quiet = true; + opts.verbose = false; } else if (arg == "--json") { opts.jsonOutput = true; } else if (arg == "--schema-version") { diff --git a/cli/args_parser.h b/cli/args_parser.h index fa6e270b..6d6882f1 100644 --- a/cli/args_parser.h +++ b/cli/args_parser.h @@ -34,6 +34,7 @@ struct CLIOptions { bool listDevices = false; bool showDeviceInfo = false; bool verbose = false; + bool quiet = false; bool jsonOutput = false; std::string schemaVersion = "1.0"; diff --git a/cli/ccap_cli.cpp b/cli/ccap_cli.cpp index 9124a53d..2e31b3f8 100644 --- a/cli/ccap_cli.cpp +++ b/cli/ccap_cli.cpp @@ -91,22 +91,10 @@ int main(int argc, char* argv[]) { // Set log level based on options if (opts.verbose) { ccap::setLogLevel(ccap::LogLevel::Verbose); + } else if (opts.quiet) { + ccap::setLogLevel(ccap::LogLevel::Error); } else { - // Check if -q/--quiet was specified by looking at argv - bool quietMode = false; - for (int i = 1; i < argc; ++i) { - std::string arg = argv[i]; - if (arg == "-q" || arg == "--quiet") { - quietMode = true; - break; - } - } - - if (quietMode) { - ccap::setLogLevel(ccap::LogLevel::Error); - } else { - ccap::setLogLevel(ccap::LogLevel::Info); - } + ccap::setLogLevel(ccap::LogLevel::Info); } // Set error callback diff --git a/cli/ccap_cli_utils.cpp b/cli/ccap_cli_utils.cpp index 90bf3d1c..3c4f892a 100644 --- a/cli/ccap_cli_utils.cpp +++ b/cli/ccap_cli_utils.cpp @@ -252,6 +252,71 @@ std::unique_ptr makeWindowsCameraBackendOverride(const C return nullptr; } +struct VideoFileProperties { + double duration = 0.0; + double frameCount = 0.0; + double frameRate = 0.0; + int width = 0; + int height = 0; +}; + +VideoFileProperties queryVideoFileProperties(ccap::Provider& provider) { + VideoFileProperties properties; + properties.duration = provider.get(ccap::PropertyName::Duration); + properties.frameCount = provider.get(ccap::PropertyName::FrameCount); + properties.frameRate = provider.get(ccap::PropertyName::FrameRate); + properties.width = static_cast(provider.get(ccap::PropertyName::Width)); + properties.height = static_cast(provider.get(ccap::PropertyName::Height)); + return properties; +} + +void printVideoFileProperties(const std::string& videoPath, const VideoFileProperties& properties) { + if (!ccap::infoLogEnabled()) { + return; + } + + std::cout << "Video file: " << videoPath << std::endl; + std::cout << " Resolution: " << properties.width << "x" << properties.height << std::endl; + std::cout << " Frame rate: " << properties.frameRate << " fps" << std::endl; + std::cout << " Duration: " << properties.duration << " seconds" << std::endl; + std::cout << " Total frames: " << static_cast(properties.frameCount) << std::endl; +} + +double resolvePlaybackSpeed(const CLIOptions& opts, double sourceFrameRate, double defaultSpeed, + std::string_view defaultLogMessage, bool appendDefaultOnRateFailure) { + double playbackSpeed = defaultSpeed; + + if (opts.playbackSpeedSpecified) { + playbackSpeed = opts.playbackSpeed; + if (ccap::infoLogEnabled()) { + std::cout << " Playback speed: " << playbackSpeed << "x" << std::endl; + } + return playbackSpeed; + } + + if (opts.fpsSpecified) { + if (sourceFrameRate > 0.0) { + playbackSpeed = opts.fps / sourceFrameRate; + if (ccap::infoLogEnabled()) { + std::cout << " Calculated playback speed: " << playbackSpeed << "x (from --fps " << opts.fps << ")" + << std::endl; + } + } else { + std::cerr << "Warning: Cannot calculate playback speed, video frame rate is 0."; + if (appendDefaultOnRateFailure) { + std::cerr << " Using default " << defaultSpeed << "x."; + } + std::cerr << std::endl; + } + return playbackSpeed; + } + + if (ccap::infoLogEnabled()) { + std::cout << " Playback speed: " << defaultLogMessage << std::endl; + } + return playbackSpeed; +} + } // namespace // ============================================================================ @@ -599,47 +664,13 @@ int captureFrames(const CLIOptions& opts) { } if (provider.isFileMode()) { - // Get video properties - double duration = provider.get(ccap::PropertyName::Duration); - double frameCount = provider.get(ccap::PropertyName::FrameCount); - double frameRate = provider.get(ccap::PropertyName::FrameRate); - int width = static_cast(provider.get(ccap::PropertyName::Width)); - int height = static_cast(provider.get(ccap::PropertyName::Height)); - - // Always print video information (unless quiet mode) - if (ccap::infoLogEnabled()) { - std::cout << "Video file: " << opts.videoFilePath << std::endl; - std::cout << " Resolution: " << width << "x" << height << std::endl; - std::cout << " Frame rate: " << frameRate << " fps" << std::endl; - std::cout << " Duration: " << duration << " seconds" << std::endl; - std::cout << " Total frames: " << static_cast(frameCount) << std::endl; - } + const auto properties = queryVideoFileProperties(provider); + printVideoFileProperties(opts.videoFilePath, properties); + + const double playbackSpeed = + resolvePlaybackSpeed(opts, properties.frameRate, 0.0, + "0.0 (no frame rate control, process as fast as possible)", false); - // Calculate and set playback speed - double playbackSpeed = 0.0; - if (opts.playbackSpeedSpecified) { - playbackSpeed = opts.playbackSpeed; - if (ccap::infoLogEnabled()) { - std::cout << " Playback speed: " << playbackSpeed << "x" << std::endl; - } - } else if (opts.fpsSpecified) { - // Calculate speed from desired fps - if (frameRate > 0) { - playbackSpeed = opts.fps / frameRate; - if (ccap::infoLogEnabled()) { - std::cout << " Calculated playback speed: " << playbackSpeed << "x (from --fps " << opts.fps << ")" << std::endl; - } - } else { - std::cerr << "Warning: Cannot calculate playback speed, video frame rate is 0." << std::endl; - } - } else { - // Default: no frame rate control (0.0) - playbackSpeed = 0.0; - if (ccap::infoLogEnabled()) { - std::cout << " Playback speed: 0.0 (no frame rate control, process as fast as possible)" << std::endl; - } - } - if (playbackSpeed >= 0) { provider.set(ccap::PropertyName::PlaybackSpeed, playbackSpeed); } @@ -1198,45 +1229,12 @@ int runPreview(const CLIOptions& opts) { return 1; } - // Get video properties and print information - double videoFrameRate = provider.get(ccap::PropertyName::FrameRate); - double duration = provider.get(ccap::PropertyName::Duration); - double frameCount = provider.get(ccap::PropertyName::FrameCount); - int videoWidth = static_cast(provider.get(ccap::PropertyName::Width)); - int videoHeight = static_cast(provider.get(ccap::PropertyName::Height)); - - if (ccap::infoLogEnabled()) { - std::cout << "Video file: " << opts.videoFilePath << std::endl; - std::cout << " Resolution: " << videoWidth << "x" << videoHeight << std::endl; - std::cout << " Frame rate: " << videoFrameRate << " fps" << std::endl; - std::cout << " Duration: " << duration << " seconds" << std::endl; - std::cout << " Total frames: " << static_cast(frameCount) << std::endl; - } - - // Calculate and set playback speed - double playbackSpeed = 1.0; // Default for preview mode - if (opts.playbackSpeedSpecified) { - playbackSpeed = opts.playbackSpeed; - if (ccap::infoLogEnabled()) { - std::cout << " Playback speed: " << playbackSpeed << "x" << std::endl; - } - } else if (opts.fpsSpecified) { - // Calculate speed from desired fps - if (videoFrameRate > 0) { - playbackSpeed = opts.fps / videoFrameRate; - if (ccap::infoLogEnabled()) { - std::cout << " Calculated playback speed: " << playbackSpeed << "x (from --fps " << opts.fps << ")" << std::endl; - } - } else { - std::cerr << "Warning: Cannot calculate playback speed, video frame rate is 0. Using default 1.0x." << std::endl; - } - } else { - // Default 1.0 for preview mode - if (ccap::infoLogEnabled()) { - std::cout << " Playback speed: 1.0x (normal speed)" << std::endl; - } - } - + const auto properties = queryVideoFileProperties(provider); + printVideoFileProperties(opts.videoFilePath, properties); + + const double playbackSpeed = resolvePlaybackSpeed(opts, properties.frameRate, 1.0, + "1.0x (normal speed)", true); + provider.set(ccap::PropertyName::PlaybackSpeed, playbackSpeed); #else std::cerr << "Video file playback is not supported. Rebuild with CCAP_ENABLE_FILE_PLAYBACK=ON" << std::endl; diff --git a/docs/content/c-interface.md b/docs/content/c-interface.md index 0a7505dd..bc94eb88 100644 --- a/docs/content/c-interface.md +++ b/docs/content/c-interface.md @@ -9,6 +9,7 @@ The ccap C interface provides complete camera capture functionality for C langua - Device discovery and management - Camera configuration and control - Synchronous and asynchronous frame capture +- Video file writing on Windows/macOS (when enabled) - Memory management ## Core Concepts @@ -19,6 +20,7 @@ The C interface uses opaque pointers to hide C++ object implementation details: - `CcapProvider*` - Encapsulates `ccap::Provider` object - `CcapVideoFrame*` - Encapsulates `ccap::VideoFrame` shared pointer +- `CcapVideoWriter*` - Encapsulates `ccap::VideoWriter` object ### Memory Management @@ -27,6 +29,7 @@ The C interface follows these memory management principles: 1. **Creation and Destruction**: All objects created via `ccap_xxx_create()` must be released via the corresponding `ccap_xxx_destroy()` 2. **Array Release**: String arrays and struct arrays returned have dedicated release functions 3. **Frame Management**: Frames acquired via `ccap_provider_grab()` must be released via `ccap_video_frame_release()` +4. **Writer Management**: Writers created via `ccap_video_writer_create()` must be released via `ccap_video_writer_destroy()` ## Basic Usage Flow @@ -148,6 +151,41 @@ ccap_provider_close(provider); ccap_provider_destroy(provider); ``` +### 8. Optional: Video Writing (Windows/macOS) + +When built with `CCAP_ENABLE_VIDEO_WRITER=ON`, the C API can write camera frames to MP4/MOV files. + +```c +#include "ccap_writer_c.h" + +CcapVideoWriter* writer = ccap_video_writer_create(); +if (writer) { + CcapWriterConfig cfg = { + .codec = CCAP_VIDEO_CODEC_H264, + .container = CCAP_VIDEO_FORMAT_MP4, + .width = 1280, + .height = 720, + .frameRate = 30.0, + .bitRate = 0 + }; + + if (ccap_video_writer_open(writer, "camera_record.mp4", &cfg)) { + CcapVideoFrame* frame = ccap_provider_grab(provider, 1000); + if (frame) { + CcapVideoFrameInfo info; + if (ccap_video_frame_get_info(frame, &info)) { + // timestampNs == 0 means auto timestamp generation from cfg.frameRate. + ccap_video_writer_write_frame(writer, &info, 0); + } + ccap_video_frame_release(frame); + } + ccap_video_writer_close(writer); + } + + ccap_video_writer_destroy(writer); +} +``` + ## Complete Example See `examples/ccap_c_example.c` for a complete usage example. @@ -202,6 +240,7 @@ gcc -std=c99 ccap_c_example.c -o ccap_c_example \ - `CcapProvider*` - Provider object pointer - `CcapVideoFrame*` - Video frame object pointer +- `CcapVideoWriter*` - Video writer object pointer - `CcapPixelFormat` - Pixel format enumeration - `CcapPropertyName` - Property name enumeration - `CcapVideoFrameInfo` - Frame information structure @@ -228,6 +267,15 @@ gcc -std=c99 ccap_c_example.c -o ccap_c_example \ - `ccap_provider_grab()` - Synchronously acquire frame - `ccap_provider_set_new_frame_callback()` - Set asynchronous callback +#### Video Writing (Windows/macOS) +- `ccap_video_writer_create()` - Create writer +- `ccap_video_writer_destroy()` - Destroy writer +- `ccap_video_writer_open()` - Open output file with writer config +- `ccap_video_writer_write_frame()` - Write one frame +- `ccap_video_writer_close()` - Close writer +- `ccap_video_writer_is_opened()` - Check writer open state +- `ccap_video_writer_actual_codec()` - Query actual codec used after fallback + #### Property Configuration - `ccap_provider_set_property()` - Set property - `ccap_provider_get_property()` - Get property diff --git a/docs/content/cli.md b/docs/content/cli.md index 9a5d85e9..1edbcda4 100644 --- a/docs/content/cli.md +++ b/docs/content/cli.md @@ -13,6 +13,7 @@ The `ccap` CLI tool provides a comprehensive command-line interface for working - **Format Support**: RGB, BGR, RGBA, BGRA, YUV (NV12, I420, YUYV, UYVY) - **YUV Operations**: Direct YUV capture and YUV-to-image conversion - **Real-time Preview**: OpenGL-based preview window (when built with GLFW support) +- **Video Recording**: Record camera streams to MP4/MOV with `--record` (Windows/macOS) - **Automation Friendly**: Designed for scripts and CI/CD pipelines - **Cross-platform**: Windows, macOS, Linux @@ -127,6 +128,18 @@ These options are available on Windows only. | `--format, --output-format` | - | Output pixel format (see [Supported Formats](#supported-formats)) | | `--internal-format FORMAT` | - | Camera's internal pixel format (camera mode only) | +### Video Recording Options (Camera Mode) + +| Option | Description | +|--------|-------------| +| `--record FILE` | Record camera frames to a video file (`.mp4` / `.mov`) | + +Recording notes: + +- `--record` is **camera mode only**. In video-file input mode (`-i video.mp4`), this option is ignored with a warning. +- Recording is available only when built with `CCAP_ENABLE_VIDEO_WRITER=ON` on supported platforms (Windows/macOS). +- Use `-c/--count` or `--timeout` to stop automatically; otherwise recording continues until the process exits. + ### Save Options | Option | Default | Description | @@ -324,6 +337,23 @@ ccap -i /path/to/video.mp4 --preview --fps 60 ccap -i /path/to/video.mp4 --preview --fps 15 ``` +### Video Recording + +Record 5 seconds from the default camera: +```bash +ccap -d 0 --record ./camera_capture.mp4 --timeout 5 +``` + +Record a fixed number of frames: +```bash +ccap -d 0 -c 150 --record ./camera_capture.mp4 +``` + +Record with explicit capture configuration: +```bash +ccap -d 0 -w 1280 -H 720 -f 30 --record ./camera_capture.mov --timeout 8 +``` + ### Format-Specific Capture Capture frames in BGR24 format: diff --git a/docs/content/cmake-options.md b/docs/content/cmake-options.md index 6079d5df..5e91438d 100644 --- a/docs/content/cmake-options.md +++ b/docs/content/cmake-options.md @@ -84,6 +84,25 @@ cmake --build build **Recommendation:** Use for production builds only. +#### `CCAP_ENABLE_VIDEO_WRITER` +**Enable video writing support (`VideoWriter`, C writer API, CLI `--record`)** + +- **Type**: Boolean (ON/OFF) +- **Default**: `ON` +- **Platforms**: Windows, macOS +- **Usage**: `-DCCAP_ENABLE_VIDEO_WRITER=ON` + +**What it controls:** +- C++ API: `ccap::VideoWriter` +- C API: `ccap_video_writer_*` +- CLI: `--record` +- Example: `6-record_video` +- Tests: `ccap_video_writer_test` (when tests are enabled) + +**Notes:** +- This option is independent from `CCAP_ENABLE_FILE_PLAYBACK`. +- On unsupported platforms (for example Linux), writer functionality is not built even if this option is set to `ON`. + #### `CCAP_INSTALL` **Enable installation targets** @@ -422,6 +441,7 @@ See `cmake/dev.cmake.example` for more examples. | `CCAP_BUILD_TESTS` | OFF | Unit tests | Development, CI | | `CCAP_BUILD_CLI` | OFF | CLI tool | Automation, scripting | | `CCAP_BUILD_CLI_STANDALONE` | OFF | Portable CLI | Distribution | +| `CCAP_ENABLE_VIDEO_WRITER` | ON | Video writing APIs and CLI recording | Windows/macOS recording workflows | | `CCAP_FORCE_ARM64` | OFF | ARM compilation | ARM devices, M1/M2 | *Depends on whether ccap is the root project diff --git a/docs/content/documentation.md b/docs/content/documentation.md index 49b73622..9f26837f 100644 --- a/docs/content/documentation.md +++ b/docs/content/documentation.md @@ -9,6 +9,7 @@ - Cross-platform: Windows, macOS, iOS, Linux - Dual API: Modern C++17 and pure C99 - Video file playback (Windows & macOS) - play MP4, AVI, MOV, MKV and other formats +- Video writing/recording (Windows & macOS) via `VideoWriter`, C writer API, and CLI `--record` - Command-line tool for scripting, automation, and video processing - **Language bindings:** [C Interface](c-interface.md) and [Rust Bindings](rust-bindings.md) @@ -188,6 +189,38 @@ if (provider.open("/path/to/video.mp4", true)) { **Note**: Video playback is currently not supported on Linux. +## Video Writing + +ccap supports video writing on Windows and macOS (when built with `CCAP_ENABLE_VIDEO_WRITER=ON`). + +```cpp +#include +#include + +ccap::Provider provider; +ccap::VideoWriter writer; + +if (provider.open("", true)) { + ccap::WriterConfig cfg; + cfg.width = 1280; + cfg.height = 720; + cfg.frameRate = 30.0; + cfg.codec = ccap::VideoCodec::H264; + cfg.container = ccap::VideoFormat::MP4; + + if (writer.open("camera_record.mp4", cfg)) { + while (auto frame = provider.grab(3000)) { + writer.writeFrame(*frame, 0); // 0 => auto timestamp from frameRate + } + writer.close(); + } +} +``` + +Writer input supports `NV12`, `I420`, `BGR24`, and `BGRA32`. + +`VideoFrame::orientation` is honored by the writer path, including `BottomToTop` frames common on Windows RGB capture. + ## Properties | Property | Description | @@ -224,6 +257,8 @@ Uses DirectShow for camera access by default on Windows to preserve compatibilit For most Windows applications, `auto` mode is the recommended choice. ccap merges device enumeration across both backends and keeps the public capture API, frame orientation handling, and output pixel-format conversion aligned so callers usually do not need backend-specific branching. +Windows camera backend selection (`auto`/`dshow`/`msmf`) applies to capture only. Video writing uses Media Foundation's writer stack. + To force a specific camera backend on Windows, either pass `extraInfo` as `auto`, `msmf`, `dshow`, or `backend=` to the constructors that accept it, or set `CCAP_WINDOWS_BACKEND=auto|msmf|dshow` for the current process. ```shell diff --git a/skills/ccap/SKILL.md b/skills/ccap/SKILL.md index bd08f438..dfcb7754 100644 --- a/skills/ccap/SKILL.md +++ b/skills/ccap/SKILL.md @@ -1,7 +1,7 @@ --- name: ccap description: "Install or use the ccap CLI for camera capture, webcam inspection, device listing, frame capture, and video metadata. Use when you need to work with CameraCapture on macOS, Linux, or Windows, especially for listing devices, checking device capabilities, capturing frames, inspecting video files, or choosing between existing install, Homebrew, source build, and release-binary fallback." -argument-hint: "install | list-devices | device-info | capture | video-info" +argument-hint: "install | list-devices | device-info | capture | record | video-info" metadata: { "openclaw": { "emoji": "📷", "homepage": "https://github.com/wysaid/CameraCapture", "install": [{ "id": "brew", "kind": "brew", "formula": "wysaid/ccap/ccap", "bins": ["ccap"], "os": ["macos"], "label": "Install ccap (Homebrew)" }] } } --- @@ -18,6 +18,8 @@ Use this skill when the user asks for things like: - "list my cameras" - "show device capabilities" - "capture one frame from webcam 0" +- "record 5 seconds from webcam 0" +- "save webcam stream to mp4" - "inspect this mp4" - "install ccap on this machine" - "use CameraCapture from the CLI" @@ -31,6 +33,7 @@ Use this skill when the user asks for things like: - Listing camera devices - Inspecting device capabilities - Capturing one or more frames with the CLI +- Recording camera streams to MP4/MOV with the CLI - Reading video metadata ## What This Skill Is Not For @@ -74,6 +77,7 @@ Use these first before improvising: - Device info: `ccap --device-info 0 --json` - Video info: `ccap -i /path/to/video.mp4 --json` - Capture one frame: `ccap -d 0 -c 1 -o ./captures` +- Record camera stream: `ccap -d 0 --record ./camera_capture.mp4 --timeout 5` - Capture one frame from a named device: `ccap -d "OBS Virtual Camera" -c 1 -o ./captures` More examples and fallback notes are in [command reference](./references/commands.md). @@ -86,6 +90,8 @@ More examples and fallback notes are in [command reference](./references/command - If there is no camera device, consider a video-file workflow if that satisfies the task. - If the environment is headless or remote, do not enable preview by default. - If video playback is unsupported on the current platform or build, report it explicitly. +- If recording is requested, verify writer support first (`CCAP_ENABLE_VIDEO_WRITER=ON` and supported platform: Windows/macOS). +- `--record` is camera-mode only. If input is a video file (`-i`), report that recording is ignored. ## Response Expectations diff --git a/src/ccap_file_reader_windows.cpp b/src/ccap_file_reader_windows.cpp index 78c28a50..ec23c6fe 100644 --- a/src/ccap_file_reader_windows.cpp +++ b/src/ccap_file_reader_windows.cpp @@ -321,14 +321,17 @@ bool FileReaderWindows::start() { return m_isStarted; } + if (m_readThread.joinable()) { + m_readThread.join(); + } + m_shouldStop = false; m_isStarted = true; // Start read thread - std::thread readThread([this]() { + m_readThread = std::thread([this]() { readLoop(); }); - readThread.detach(); return true; } @@ -337,10 +340,12 @@ void FileReaderWindows::stop() { m_shouldStop = true; m_isStarted = false; - // Wait for reading to finish - int waitCount = 0; - while (m_isReading && waitCount++ < 100) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); + if (m_sourceReader) { + m_sourceReader->Flush(MF_SOURCE_READER_FIRST_VIDEO_STREAM); + } + + if (m_readThread.joinable()) { + m_readThread.join(); } } @@ -410,6 +415,12 @@ void FileReaderWindows::readLoop() { hr = buffer->Lock(&data, &maxLen, ¤tLen); if (SUCCEEDED(hr) && data && m_provider) { auto newFrame = m_provider->getFreeFrame(); + if (!newFrame) { + buffer->Unlock(); + buffer->Release(); + sample->Release(); + continue; + } newFrame->timestamp = static_cast(timestamp * 100); // 100ns to ns newFrame->width = static_cast(m_width); diff --git a/src/ccap_file_reader_windows.h b/src/ccap_file_reader_windows.h index e5a622ab..8f982388 100644 --- a/src/ccap_file_reader_windows.h +++ b/src/ccap_file_reader_windows.h @@ -19,6 +19,7 @@ #include #include #include +#include struct IMFSourceReader; struct IMFMediaType; @@ -99,6 +100,8 @@ class FileReaderWindows { std::atomic m_shouldStop{ false }; std::atomic m_isReading{ false }; + std::thread m_readThread; + bool m_mfInitialized = false; }; diff --git a/src/ccap_writer.cpp b/src/ccap_writer.cpp index d28bfb46..382fdb35 100644 --- a/src/ccap_writer.cpp +++ b/src/ccap_writer.cpp @@ -52,7 +52,10 @@ bool VideoWriter::isOpened() const { } bool VideoWriter::writeFrame(const VideoFrame& frame, uint64_t timestampNs) { - if (!m_impl) return false; + if (!m_impl) { + reportError(ErrorCode::WriterNotOpened, "VideoWriter not available on this platform"); + return false; + } return impl(m_impl)->writeFrame(frame, timestampNs); } diff --git a/src/ccap_writer_c.cpp b/src/ccap_writer_c.cpp index f5083f04..63df94e4 100644 --- a/src/ccap_writer_c.cpp +++ b/src/ccap_writer_c.cpp @@ -22,8 +22,11 @@ CcapVideoWriter* ccap_video_writer_create(void) { } void ccap_video_writer_destroy(CcapVideoWriter* writer) { - if (writer) { + if (!writer) return; + try { delete reinterpret_cast(writer); + } catch (...) { + // Never throw across C ABI boundary. } } @@ -49,14 +52,21 @@ bool ccap_video_writer_open(CcapVideoWriter* writer, const char* filePath, } void ccap_video_writer_close(CcapVideoWriter* writer) { - if (writer) { + if (!writer) return; + try { reinterpret_cast(writer)->close(); + } catch (...) { + // Never throw across C ABI boundary. } } bool ccap_video_writer_is_opened(const CcapVideoWriter* writer) { if (!writer) return false; - return reinterpret_cast(writer)->isOpened(); + try { + return reinterpret_cast(writer)->isOpened(); + } catch (...) { + return false; + } } bool ccap_video_writer_write_frame(CcapVideoWriter* writer, @@ -75,15 +85,15 @@ bool ccap_video_writer_write_frame(CcapVideoWriter* writer, frame.data[i] = frameInfo->data[i]; frame.stride[i] = frameInfo->stride[i]; } + const uint64_t resolvedTimestamp = timestampNs > 0 ? timestampNs : frameInfo->timestamp; frame.pixelFormat = static_cast(static_cast(frameInfo->pixelFormat)); frame.width = frameInfo->width; frame.height = frameInfo->height; frame.sizeInBytes = frameInfo->sizeInBytes; - frame.timestamp = timestampNs > 0 ? timestampNs : frameInfo->timestamp; + frame.timestamp = resolvedTimestamp; frame.frameIndex = frameInfo->frameIndex; frame.orientation = static_cast(static_cast(frameInfo->orientation)); - uint64_t resolvedTimestamp = timestampNs > 0 ? timestampNs : frameInfo->timestamp; return cppWriter->writeFrame(frame, resolvedTimestamp); } catch (...) { return false; @@ -92,8 +102,12 @@ bool ccap_video_writer_write_frame(CcapVideoWriter* writer, CcapVideoCodec ccap_video_writer_actual_codec(const CcapVideoWriter* writer) { if (!writer) return CCAP_VIDEO_CODEC_H264; - auto* cppWriter = reinterpret_cast(writer); - return (cppWriter->actualCodec() == ccap::VideoCodec::HEVC) ? CCAP_VIDEO_CODEC_HEVC : CCAP_VIDEO_CODEC_H264; + try { + auto* cppWriter = reinterpret_cast(writer); + return (cppWriter->actualCodec() == ccap::VideoCodec::HEVC) ? CCAP_VIDEO_CODEC_HEVC : CCAP_VIDEO_CODEC_H264; + } catch (...) { + return CCAP_VIDEO_CODEC_H264; + } } } // extern "C" diff --git a/tests/test_ccap_cli.cpp b/tests/test_ccap_cli.cpp index 7482dd57..4cf95b11 100644 --- a/tests/test_ccap_cli.cpp +++ b/tests/test_ccap_cli.cpp @@ -129,11 +129,14 @@ CommandResult executeCommandCapturingStdoutOnly(const std::string& command, cons #endif if (fs::exists(stderrPath)) { - std::ifstream stderrFile(stderrPath, std::ios::binary); - std::ostringstream stderrStream; - stderrStream << stderrFile.rdbuf(); - result.error = stderrStream.str(); - fs::remove(stderrPath); + { + std::ifstream stderrFile(stderrPath, std::ios::binary); + std::ostringstream stderrStream; + stderrStream << stderrFile.rdbuf(); + result.error = stderrStream.str(); + } + std::error_code ec; + fs::remove(stderrPath, ec); } return result; @@ -635,7 +638,17 @@ class CCAPCLITest : public ::testing::Test { CommandResult runCLIJson(const std::string& args) { fs::path stderrPath = testOutputDir / ("stderr_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".log"); std::string fullCmd = cliPath + " " + args; - return executeCommandCapturingStdoutOnly(fullCmd, stderrPath); + auto result = executeCommandCapturingStdoutOnly(fullCmd, stderrPath); + + // Some environments print informational logs to stdout before JSON payload. + // Keep only the JSON envelope to make parsing stable across platforms. + constexpr std::string_view kJsonEnvelopePrefix = "{\"schema_version\""; + size_t jsonPos = result.output.find(kJsonEnvelopePrefix); + if (jsonPos != std::string::npos) { + result.output = result.output.substr(jsonPos); + } + + return result; } }; diff --git a/tests/test_cli_args_parser.cpp b/tests/test_cli_args_parser.cpp index 346db073..a6b11cde 100644 --- a/tests/test_cli_args_parser.cpp +++ b/tests/test_cli_args_parser.cpp @@ -58,6 +58,37 @@ TEST(CLIArgsParserTest, ParsesJsonOutputOptions) { EXPECT_EQ(opts.schemaVersion, "1.2"); } +TEST(CLIArgsParserTest, ParsesQuietOption) { + char arg0[] = "ccap"; + char arg1[] = "-q"; + char* argv[] = { arg0, arg1, nullptr }; + + const ccap_cli::CLIOptions opts = ccap_cli::parseArgs(2, argv); + + EXPECT_TRUE(opts.quiet); + EXPECT_FALSE(opts.verbose); +} + +TEST(CLIArgsParserTest, LastLogVerbosityFlagWins) { + char arg0[] = "ccap"; + char arg1[] = "--verbose"; + char arg2[] = "--quiet"; + char* argv1[] = { arg0, arg1, arg2, nullptr }; + + const ccap_cli::CLIOptions quietWins = ccap_cli::parseArgs(3, argv1); + EXPECT_TRUE(quietWins.quiet); + EXPECT_FALSE(quietWins.verbose); + + char arg3[] = "ccap"; + char arg4[] = "--quiet"; + char arg5[] = "--verbose"; + char* argv2[] = { arg3, arg4, arg5, nullptr }; + + const ccap_cli::CLIOptions verboseWins = ccap_cli::parseArgs(3, argv2); + EXPECT_FALSE(verboseWins.quiet); + EXPECT_TRUE(verboseWins.verbose); +} + TEST(CLIArgsParserTest, RejectsMissingSchemaVersionValue) { char arg0[] = "ccap"; char arg1[] = "--schema-version"; From e9984e957c0f4b88f62c148ebbc83678ae1c1921 Mon Sep 17 00:00:00 2001 From: "wangyang (wysaid)" Date: Sun, 17 May 2026 04:57:02 +0800 Subject: [PATCH 09/10] fix: address review feedback for video writer and CLI parsing --- cli/args_parser.cpp | 13 ++- examples/desktop/6-record_video.cpp | 2 +- include/ccap_writer.h | 2 + include/ccap_writer_c.h | 17 +++- src/ccap_writer.cpp | 13 ++- src/ccap_writer_apple.mm | 30 +++--- src/ccap_writer_imp.h | 6 +- src/ccap_writer_windows.cpp | 19 +++- tests/test_cli_args_parser.cpp | 44 +++++++++ tests/test_video_writer.cpp | 144 +++++++++++++++++----------- 10 files changed, 207 insertions(+), 83 deletions(-) diff --git a/cli/args_parser.cpp b/cli/args_parser.cpp index efa65ff2..b6ce8838 100644 --- a/cli/args_parser.cpp +++ b/cli/args_parser.cpp @@ -404,9 +404,18 @@ CLIOptions parseArgs(int argc, char* argv[]) { opts.videoFilePath = argv[++i]; } } else if (arg == "--record") { - if (i + 1 < argc) { - opts.recordVideoPath = argv[++i]; +#ifdef CCAP_ENABLE_VIDEO_WRITER + if (i + 1 >= argc || argv[i + 1][0] == '-') { + std::cerr << "Error: --record requires an output file path.\n\n"; + printUsage(argv[0]); + std::exit(1); } + opts.recordVideoPath = argv[++i]; +#else + std::cerr << "Error: --record is not supported in this build. Rebuild with CCAP_ENABLE_VIDEO_WRITER=ON.\n\n"; + printUsage(argv[0]); + std::exit(1); +#endif } else if (arg == "-w" || arg == "--width") { if (i + 1 < argc) { opts.width = std::atoi(argv[++i]); diff --git a/examples/desktop/6-record_video.cpp b/examples/desktop/6-record_video.cpp index d8ad3f66..3b78ccc5 100644 --- a/examples/desktop/6-record_video.cpp +++ b/examples/desktop/6-record_video.cpp @@ -50,7 +50,7 @@ int main(int argc, char** argv) { outputPath = commandLine.argv[1]; } else { std::string exeDir = commandLine.argv[0]; - if (auto pos = exeDir.find_last_of("/\\"); pos != std::string::npos && exeDir[0] != '.') { + if (auto pos = exeDir.find_last_of("/\\"); pos != std::string::npos && !exeDir.empty() && exeDir[0] != '.') { exeDir = exeDir.substr(0, pos); } else { exeDir = std::filesystem::current_path().string(); diff --git a/include/ccap_writer.h b/include/ccap_writer.h index 28da89f7..f6cdeed0 100644 --- a/include/ccap_writer.h +++ b/include/ccap_writer.h @@ -70,6 +70,7 @@ class CCAP_EXPORT VideoWriter { * @brief Open writer to a file path. * @param filePath Output file path (e.g., "output.mp4") * @param config Writer configuration (width, height, codec, etc.) + * @note Call `close()` before reopening an existing writer instance. * @return true on success, false on failure. */ bool open(std::string_view filePath, const WriterConfig& config); @@ -87,6 +88,7 @@ class CCAP_EXPORT VideoWriter { bool writeFrame(const VideoFrame& frame, uint64_t timestampNs = 0); /// Query the actual codec being used (may differ from config due to fallback). + /// Only meaningful after `open()` succeeds. VideoCodec actualCodec() const; uint32_t width() const; diff --git a/include/ccap_writer_c.h b/include/ccap_writer_c.h index e5f03150..27b1d9a3 100644 --- a/include/ccap_writer_c.h +++ b/include/ccap_writer_c.h @@ -39,16 +39,26 @@ typedef enum { /* ========== Data Structures ========== */ -/** @brief Video writer configuration */ +/** + * @brief Video writer configuration. + * @note Use `CCAP_WRITER_CONFIG_INIT` for codec/container/frameRate/bitRate defaults. + * `width` and `height` must still be set before opening a writer. + */ typedef struct { CcapVideoCodec codec; ///< Preferred codec CcapVideoFormat container; ///< Container format uint32_t width; ///< Frame width uint32_t height; ///< Frame height - double frameRate; ///< Target frame rate (default 30fps) + double frameRate; ///< Target frame rate; 0 lets open() normalize to 30fps uint64_t bitRate; ///< Target bit rate in bits/s (0 = auto) } CcapWriterConfig; +/** + * @brief Default initializer for `CcapWriterConfig`. + * @note `width` and `height` remain 0 and must be assigned by the caller. + */ +#define CCAP_WRITER_CONFIG_INIT { CCAP_VIDEO_CODEC_HEVC, CCAP_VIDEO_FORMAT_MP4, 0u, 0u, 30.0, 5000000ULL } + /* ========== Writer Lifecycle ========== */ /** @@ -100,7 +110,8 @@ CCAP_EXPORT bool ccap_video_writer_write_frame(CcapVideoWriter* writer, /** * @brief Get the actual codec being used (may differ from config due to fallback) * @param writer Pointer to CcapVideoWriter instance - * @return Actual codec enum value + * @return Actual codec enum value. Only meaningful after `ccap_video_writer_open()` succeeds. + * Unopened or null writers return `CCAP_VIDEO_CODEC_H264` for ABI compatibility. */ CCAP_EXPORT CcapVideoCodec ccap_video_writer_actual_codec(const CcapVideoWriter* writer); diff --git a/src/ccap_writer.cpp b/src/ccap_writer.cpp index 382fdb35..818b7eb2 100644 --- a/src/ccap_writer.cpp +++ b/src/ccap_writer.cpp @@ -16,13 +16,15 @@ namespace ccap { static VideoWriter::Impl* impl(void* p) { return reinterpret_cast(p); } static const VideoWriter::Impl* impl(const void* p) { return reinterpret_cast(p); } -VideoWriter::VideoWriter() : m_impl(createVideoWriterImpl()) {} +VideoWriter::VideoWriter() : + m_impl(createVideoWriterImpl()) {} VideoWriter::~VideoWriter() { delete impl(m_impl); } -VideoWriter::VideoWriter(VideoWriter&& other) noexcept : m_impl(other.m_impl) { +VideoWriter::VideoWriter(VideoWriter&& other) noexcept : + m_impl(other.m_impl) { other.m_impl = nullptr; } @@ -40,6 +42,10 @@ bool VideoWriter::open(std::string_view filePath, const WriterConfig& config) { reportError(ErrorCode::WriterNotOpened, "VideoWriter not available on this platform"); return false; } + if (impl(m_impl)->isOpened()) { + reportError(ErrorCode::WriterOpenFailed, "VideoWriter is already opened. Call close() before reopening."); + return false; + } return impl(m_impl)->open(filePath, config); } @@ -81,7 +87,8 @@ double VideoWriter::frameRate() const { namespace ccap { -VideoWriter::VideoWriter() : m_impl(nullptr) {} +VideoWriter::VideoWriter() : + m_impl(nullptr) {} VideoWriter::~VideoWriter() = default; VideoWriter::VideoWriter(VideoWriter&&) noexcept = default; VideoWriter& VideoWriter::operator=(VideoWriter&&) noexcept = default; diff --git a/src/ccap_writer_apple.mm b/src/ccap_writer_apple.mm index ab63d377..d6d951e4 100644 --- a/src/ccap_writer_apple.mm +++ b/src/ccap_writer_apple.mm @@ -160,22 +160,24 @@ void close() override { if (!m_isOpened) return; m_isOpened = false; + AVAssetWriter* assetWriter = m_assetWriter; + AVAssetWriterInput* writerInput = m_writerInput; + @try { - if (m_writerInput) { - [m_writerInput markAsFinished]; + if (writerInput) { + [writerInput markAsFinished]; } - if (m_assetWriter) { - dispatch_queue_t queue = dispatch_queue_create("com.ccap.writer.close", DISPATCH_QUEUE_SERIAL); + if (assetWriter) { dispatch_semaphore_t sem = dispatch_semaphore_create(0); - dispatch_async(queue, ^{ - [m_assetWriter finishWritingWithCompletionHandler:^{ - dispatch_semaphore_signal(sem); - }]; - }); - dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); - - if (m_assetWriter.error) { - reportError(ErrorCode::WriterCloseFailed, "finishWriting failed: " + std::string(m_assetWriter.error.localizedDescription.UTF8String)); + [assetWriter finishWritingWithCompletionHandler:^{ + dispatch_semaphore_signal(sem); + }]; + + const long waitResult = dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + if (waitResult != 0) { + reportError(ErrorCode::WriterCloseFailed, "finishWriting timed out after 10 seconds"); + } else if (assetWriter.error) { + reportError(ErrorCode::WriterCloseFailed, "finishWriting failed: " + std::string(assetWriter.error.localizedDescription.UTF8String)); } } } @@ -186,6 +188,8 @@ void close() override { m_pixelBufferAdaptor = nil; m_writerInput = nil; m_assetWriter = nil; + m_sessionStarted = NO; + m_frameCount = 0; std::memset(&m_config, 0, sizeof(m_config)); } diff --git a/src/ccap_writer_imp.h b/src/ccap_writer_imp.h index e0c35400..4b173f5b 100644 --- a/src/ccap_writer_imp.h +++ b/src/ccap_writer_imp.h @@ -20,7 +20,8 @@ namespace ccap { void reportError(ErrorCode errorCode, std::string_view description); struct VideoWriter::Impl { - Impl() : m_actualCodec(VideoCodec::H264) {} + Impl() : + m_actualCodec(VideoCodec::H264) {} virtual ~Impl() = default; virtual bool open(std::string_view filePath, const WriterConfig& config) = 0; @@ -76,6 +77,9 @@ inline bool convertFrameToNv12(const VideoFrame& frame, uint32_t& yStride, uint32_t& uvStride) { const int w = static_cast(frame.width); const int h = static_cast(frame.height); + if (w <= 0 || h <= 0 || (w % 2) != 0 || (h % 2) != 0) { + return false; + } const int w2 = w / 2; const int h2 = h / 2; const FrameOrientation orientation = frame.orientation; diff --git a/src/ccap_writer_windows.cpp b/src/ccap_writer_windows.cpp index ded5157d..e17c45c7 100644 --- a/src/ccap_writer_windows.cpp +++ b/src/ccap_writer_windows.cpp @@ -14,11 +14,13 @@ #define NOMINMAX #endif #include +#include #include #include #include #include #include +#include #include #include @@ -31,9 +33,22 @@ namespace ccap { +namespace { + +std::string formatHRESULT(HRESULT hr) { + std::ostringstream stream; + stream << "0x" + << std::uppercase << std::hex << std::setw(8) << std::setfill('0') + << static_cast(hr); + return stream.str(); +} + +} // namespace + class WriterWindows : public VideoWriter::Impl { public: - WriterWindows() : m_sinkWriter(nullptr), m_streamIndex(0), m_mfInitialized(false) { + WriterWindows() : + m_sinkWriter(nullptr), m_streamIndex(0), m_mfInitialized(false) { HRESULT hr = MFStartup(MF_VERSION, MFSTARTUP_FULL); m_mfInitialized = SUCCEEDED(hr); if (!m_mfInitialized) { @@ -103,7 +118,7 @@ class WriterWindows : public VideoWriter::Impl { if (m_sinkWriter) { HRESULT hr = m_sinkWriter->Finalize(); if (FAILED(hr)) { - reportError(ErrorCode::WriterCloseFailed, "IMFSinkWriter::Finalize failed: 0x" + std::to_string(hr)); + reportError(ErrorCode::WriterCloseFailed, "IMFSinkWriter::Finalize failed: " + formatHRESULT(hr)); } m_sinkWriter->Release(); m_sinkWriter = nullptr; diff --git a/tests/test_cli_args_parser.cpp b/tests/test_cli_args_parser.cpp index a6b11cde..197e3872 100644 --- a/tests/test_cli_args_parser.cpp +++ b/tests/test_cli_args_parser.cpp @@ -103,6 +103,50 @@ TEST(CLIArgsParserTest, RejectsMissingSchemaVersionValue) { "--schema-version requires a value"); } +#ifdef CCAP_ENABLE_VIDEO_WRITER +TEST(CLIArgsParserTest, ParsesRecordOutputPath) { + char arg0[] = "ccap"; + char arg1[] = "--record"; + char arg2[] = "capture.mp4"; + char* argv[] = { arg0, arg1, arg2, nullptr }; + + const ccap_cli::CLIOptions opts = ccap_cli::parseArgs(3, argv); + + EXPECT_EQ(opts.recordVideoPath, "capture.mp4"); +} + +TEST(CLIArgsParserTest, RejectsMissingRecordValue) { + char arg0[] = "ccap"; + char arg1[] = "--record"; + char arg2[] = "--timeout"; + char arg3[] = "5"; + char* argv[] = { arg0, arg1, arg2, arg3, nullptr }; + + EXPECT_EXIT( + { + (void)ccap_cli::parseArgs(4, argv); + std::exit(0); + }, + ::testing::ExitedWithCode(1), + "--record requires an output file path"); +} +#else +TEST(CLIArgsParserTest, RejectsRecordWhenWriterUnsupported) { + char arg0[] = "ccap"; + char arg1[] = "--record"; + char arg2[] = "capture.mp4"; + char* argv[] = { arg0, arg1, arg2, nullptr }; + + EXPECT_EXIT( + { + (void)ccap_cli::parseArgs(3, argv); + std::exit(0); + }, + ::testing::ExitedWithCode(1), + "--record is not supported in this build"); +} +#endif + #if defined(_WIN32) || defined(_WIN64) TEST(CLIArgsParserTest, ParsesWindowsCameraBackendOption) { char arg0[] = "ccap"; diff --git a/tests/test_video_writer.cpp b/tests/test_video_writer.cpp index c9474785..6c593188 100644 --- a/tests/test_video_writer.cpp +++ b/tests/test_video_writer.cpp @@ -166,11 +166,6 @@ bool isVideoWriterSupported() { #endif } -// Generate a unique temp path for test output -fs::path getTestOutputPath(const std::string& name) { - return fs::temp_directory_path() / ("ccap_writer_test_" + name + ".mp4"); -} - // Create a synthetic BGR24 frame with random noise std::vector createBgrFrame(int w, int h, int stride) { std::vector data(static_cast(stride) * h); @@ -182,8 +177,7 @@ std::vector createBgrFrame(int w, int h, int stride) { return data; } -// Test fixture for video writer tests -class VideoWriterTest : public ::testing::Test { +class VideoWriterTestBase : public ::testing::Test { protected: void SetUp() override { if (!isVideoWriterSupported()) { @@ -192,37 +186,43 @@ class VideoWriterTest : public ::testing::Test { } void TearDown() override { - // Clean up any test output files (best-effort, ignore errors) std::error_code ec; - for (const auto& entry : fs::directory_iterator(fs::temp_directory_path(), ec)) { - std::string filename = entry.path().filename().string(); - if (filename.find("ccap_writer_test_") == 0) { - fs::remove(entry.path(), ec); - } + for (const auto& path : m_outputPaths) { + fs::remove(path, ec); + ec.clear(); } } -}; -// Test fixture for C API tests -class VideoWriterCTest : public ::testing::Test { -protected: - void SetUp() override { - if (!isVideoWriterSupported()) { - GTEST_SKIP() << "Video writer not supported on this platform/build"; - } - } + fs::path makeTestOutputPath(std::string_view name, std::string_view extension = ".mp4") { + const auto* info = ::testing::UnitTest::GetInstance()->current_test_info(); - void TearDown() override { - std::error_code ec; - for (const auto& entry : fs::directory_iterator(fs::temp_directory_path(), ec)) { - std::string filename = entry.path().filename().string(); - if (filename.find("ccap_writer_test_") == 0) { - fs::remove(entry.path(), ec); - } + std::string fileName = "ccap_writer_test_"; + if (info) { + fileName += info->test_suite_name(); + fileName += "_"; + fileName += info->name(); + fileName += "_"; } + fileName += std::string(name); + fileName += "_"; + fileName += std::to_string(m_outputPaths.size()); + fileName += std::string(extension); + + fs::path outputPath = fs::temp_directory_path() / fileName; + m_outputPaths.push_back(outputPath); + return outputPath; } + +private: + std::vector m_outputPaths; }; +// Test fixture for video writer tests +class VideoWriterTest : public VideoWriterTestBase {}; + +// Test fixture for C API tests +class VideoWriterCTest : public VideoWriterTestBase {}; + // ---- C++ API Tests ---- TEST_F(VideoWriterTest, ConstructAndDestroy) { @@ -265,7 +265,7 @@ TEST_F(VideoWriterTest, OpenZeroDimensions) { config.frameRate = 30.0; config.bitRate = 5000000; - bool result = writer.open(getTestOutputPath("zero_dim").string(), config); + bool result = writer.open(makeTestOutputPath("zero_dim").string(), config); EXPECT_FALSE(result); } @@ -277,7 +277,7 @@ TEST_F(VideoWriterTest, OpenAndClose) { config.frameRate = 30.0; config.bitRate = 5000000; - fs::path outputPath = getTestOutputPath("open_close"); + fs::path outputPath = makeTestOutputPath("open_close"); bool result = writer.open(outputPath.string(), config); EXPECT_TRUE(result); EXPECT_TRUE(writer.isOpened()); @@ -297,7 +297,7 @@ TEST_F(VideoWriterTest, WriteFramesAndValidateFile) { config.frameRate = 30.0; config.bitRate = 2000000; - fs::path outputPath = getTestOutputPath("write_frames"); + fs::path outputPath = makeTestOutputPath("write_frames"); ASSERT_TRUE(writer.open(outputPath.string(), config)); // Create and write 30 frames (1 second at 30fps) @@ -305,7 +305,7 @@ TEST_F(VideoWriterTest, WriteFramesAndValidateFile) { int stride = w * 3; // BGR24 std::vector frameData = createBgrFrame(w, h, stride); - ccap::VideoFrame frame; + ccap::VideoFrame frame{}; frame.data[0] = frameData.data(); frame.stride[0] = static_cast(stride); frame.data[1] = nullptr; @@ -382,6 +382,22 @@ TEST_F(VideoWriterTest, SharedNv12ConversionRespectsBottomToTopOrientation) { EXPECT_EQ(bottomUv, topUv); } +TEST_F(VideoWriterTest, SharedNv12ConversionRejectsOddDimensions) { + constexpr int w = 127; + constexpr int h = 95; + constexpr int stride = w * 3; + + std::vector frameData = createBgrFrame(w, h, stride); + ccap::VideoFrame frame{}; + initializeBgrFrame(frame, frameData.data(), w, h, stride, ccap::FrameOrientation::TopToBottom); + + std::vector yBuf; + std::vector uvBuf; + uint32_t yStride = 0; + uint32_t uvStride = 0; + EXPECT_FALSE(ccap::convertFrameToNv12(frame, yBuf, uvBuf, yStride, uvStride)); +} + TEST_F(VideoWriterTest, BottomToTopFramesRoundTripUpright) { #ifdef CCAP_ENABLE_FILE_PLAYBACK constexpr int w = 128; @@ -396,7 +412,7 @@ TEST_F(VideoWriterTest, BottomToTopFramesRoundTripUpright) { config.frameRate = 30.0; config.bitRate = 8'000'000; - fs::path outputPath = getTestOutputPath("bottom_to_top_cpp"); + fs::path outputPath = makeTestOutputPath("bottom_to_top_cpp"); ccap::VideoWriter writer; ASSERT_TRUE(writer.open(outputPath.string(), config)); @@ -437,9 +453,7 @@ TEST_F(VideoWriterTest, WriteFramesWithMovContainer) { config.bitRate = 2000000; config.container = ccap::VideoFormat::MOV; - fs::path outputPath = getTestOutputPath("mov_container"); - // Change extension - outputPath.replace_extension(".mov"); + fs::path outputPath = makeTestOutputPath("mov_container", ".mov"); ASSERT_TRUE(writer.open(outputPath.string(), config)); @@ -476,7 +490,7 @@ TEST_F(VideoWriterTest, CodecFallback) { config.bitRate = 2000000; config.codec = ccap::VideoCodec::HEVC; // Request HEVC - fs::path outputPath = getTestOutputPath("codec_fallback"); + fs::path outputPath = makeTestOutputPath("codec_fallback"); ASSERT_TRUE(writer.open(outputPath.string(), config)); // Actual codec may differ from requested due to fallback @@ -494,20 +508,41 @@ TEST_F(VideoWriterTest, WriteAfterCloseFails) { config.frameRate = 30.0; config.bitRate = 2000000; - fs::path outputPath = getTestOutputPath("write_after_close"); + fs::path outputPath = makeTestOutputPath("write_after_close"); ASSERT_TRUE(writer.open(outputPath.string(), config)); writer.close(); // Writing after close should fail + int w = 320, h = 240; + int stride = w * 3; + std::vector frameData = createBgrFrame(w, h, stride); ccap::VideoFrame frame{}; - frame.data[0] = nullptr; - frame.pixelFormat = ccap::PixelFormat::BGR24; - frame.width = 320; - frame.height = 240; + initializeBgrFrame(frame, frameData.data(), w, h, stride, ccap::FrameOrientation::TopToBottom); EXPECT_FALSE(writer.writeFrame(frame)); } +TEST_F(VideoWriterTest, ReopenWhileOpenedFails) { + ccap::VideoWriter writer; + ccap::WriterConfig config; + config.width = 320; + config.height = 240; + config.frameRate = 30.0; + config.bitRate = 2000000; + + fs::path firstOutput = makeTestOutputPath("reopen_first"); + fs::path secondOutput = makeTestOutputPath("reopen_second"); + + ASSERT_TRUE(writer.open(firstOutput.string(), config)); + EXPECT_TRUE(writer.isOpened()); + EXPECT_FALSE(writer.open(secondOutput.string(), config)); + EXPECT_TRUE(writer.isOpened()); + EXPECT_EQ(writer.width(), 320u); + EXPECT_EQ(writer.height(), 240u); + + writer.close(); +} + TEST_F(VideoWriterTest, GetPropertiesAfterOpen) { ccap::VideoWriter writer; ccap::WriterConfig config; @@ -516,7 +551,7 @@ TEST_F(VideoWriterTest, GetPropertiesAfterOpen) { config.frameRate = 25.0; config.bitRate = 3000000; - fs::path outputPath = getTestOutputPath("properties"); + fs::path outputPath = makeTestOutputPath("properties"); ASSERT_TRUE(writer.open(outputPath.string(), config)); EXPECT_EQ(writer.width(), 640); @@ -556,15 +591,12 @@ TEST_F(VideoWriterCTest, OpenAndWriteFrames) { CcapVideoWriter* writer = ccap_video_writer_create(); ASSERT_NE(writer, nullptr); - CcapWriterConfig config; - config.codec = CCAP_VIDEO_CODEC_HEVC; - config.container = CCAP_VIDEO_FORMAT_MP4; + CcapWriterConfig config = CCAP_WRITER_CONFIG_INIT; config.width = 320; config.height = 240; - config.frameRate = 30.0; config.bitRate = 2000000; - fs::path outputPath = getTestOutputPath("c_api"); + fs::path outputPath = makeTestOutputPath("c_api"); ASSERT_TRUE(ccap_video_writer_open(writer, outputPath.string().c_str(), &config)); EXPECT_TRUE(ccap_video_writer_is_opened(writer)); @@ -613,15 +645,13 @@ TEST_F(VideoWriterCTest, BottomToTopFramesRoundTripUpright) { CcapVideoWriter* writer = ccap_video_writer_create(); ASSERT_NE(writer, nullptr); - CcapWriterConfig config{}; + CcapWriterConfig config = CCAP_WRITER_CONFIG_INIT; config.codec = CCAP_VIDEO_CODEC_H264; - config.container = CCAP_VIDEO_FORMAT_MP4; config.width = static_cast(w); config.height = static_cast(h); - config.frameRate = 30.0; config.bitRate = 8'000'000; - fs::path outputPath = getTestOutputPath("bottom_to_top_c_api"); + fs::path outputPath = makeTestOutputPath("bottom_to_top_c_api"); ASSERT_TRUE(ccap_video_writer_open(writer, outputPath.string().c_str(), &config)); CcapVideoFrameInfo frameInfo{}; @@ -694,7 +724,7 @@ TEST_F(VideoWriterTest, TranscodePreservesDuration) { ASSERT_GT(srcFps, 0.0); // 2. Read all frames and write them to a new file, forwarding timestamps - fs::path outputPath = getTestOutputPath("transcode_duration"); + fs::path outputPath = makeTestOutputPath("transcode_duration"); ccap::WriterConfig writerConfig; writerConfig.width = static_cast(srcWidth); @@ -764,7 +794,7 @@ TEST_F(VideoWriterTest, TranscodeWithAutoTimestampProducesDifferentDuration) { // Write with auto-timestamp (timestampNs = 0) using a HIGHER frame rate than source // This simulates the camera-slower-than-configured scenario - fs::path outputPath = getTestOutputPath("transcode_auto_ts"); + fs::path outputPath = makeTestOutputPath("transcode_auto_ts"); ccap::WriterConfig writerConfig; writerConfig.width = static_cast(srcWidth); @@ -808,12 +838,10 @@ TEST_F(VideoWriterCTest, InvalidOpenParams) { CcapVideoWriter* writer = ccap_video_writer_create(); ASSERT_NE(writer, nullptr); - CcapWriterConfig config; + CcapWriterConfig config = CCAP_WRITER_CONFIG_INIT; config.codec = CCAP_VIDEO_CODEC_H264; - config.container = CCAP_VIDEO_FORMAT_MP4; config.width = 320; config.height = 240; - config.frameRate = 30.0; config.bitRate = 2000000; // Null filePath From 52f074ce03b7ef85344af84560072d0c40182226 Mon Sep 17 00:00:00 2001 From: "wangyang (wysaid)" Date: Sun, 17 May 2026 05:32:59 +0800 Subject: [PATCH 10/10] docs/tests: address follow-up review comments --- docs/content/c-interface.md | 28 ++++++++++++++-------------- docs/content/documentation.md | 2 +- tests/test_video_writer.cpp | 9 +++++++-- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/content/c-interface.md b/docs/content/c-interface.md index bc94eb88..f846877c 100644 --- a/docs/content/c-interface.md +++ b/docs/content/c-interface.md @@ -138,20 +138,7 @@ bool frame_callback(const CcapVideoFrame* frame, void* userData) { ccap_provider_set_new_frame_callback(provider, frame_callback, NULL); ``` -### 7. Cleanup Resources - -```c -// Stop capture -ccap_provider_stop(provider); - -// Close device -ccap_provider_close(provider); - -// Destroy provider -ccap_provider_destroy(provider); -``` - -### 8. Optional: Video Writing (Windows/macOS) +### 7. Optional: Video Writing (Windows/macOS) When built with `CCAP_ENABLE_VIDEO_WRITER=ON`, the C API can write camera frames to MP4/MOV files. @@ -186,6 +173,19 @@ if (writer) { } ``` +### 8. Cleanup Resources + +```c +// Stop capture +ccap_provider_stop(provider); + +// Close device +ccap_provider_close(provider); + +// Destroy provider +ccap_provider_destroy(provider); +``` + ## Complete Example See `examples/ccap_c_example.c` for a complete usage example. diff --git a/docs/content/documentation.md b/docs/content/documentation.md index 9f26837f..36457390 100644 --- a/docs/content/documentation.md +++ b/docs/content/documentation.md @@ -217,7 +217,7 @@ if (provider.open("", true)) { } ``` -Writer input supports `NV12`, `I420`, `BGR24`, and `BGRA32`. +Writer input supports `NV12`, `NV12f`, `I420`, `I420f`, `BGR24`, and `BGRA32`. `VideoFrame::orientation` is honored by the writer path, including `BottomToTop` frames common on Windows RGB capture. diff --git a/tests/test_video_writer.cpp b/tests/test_video_writer.cpp index 6c593188..6aa1a2b3 100644 --- a/tests/test_video_writer.cpp +++ b/tests/test_video_writer.cpp @@ -692,11 +692,16 @@ TEST_F(VideoWriterCTest, BottomToTopFramesRoundTripUpright) { // Helper: locate the built-in test video by walking up from CWD to find the project root static fs::path findTestVideo() { fs::path projectRoot = fs::current_path(); - while (projectRoot.has_parent_path()) { + while (true) { if (fs::exists(projectRoot / "CMakeLists.txt") && fs::exists(projectRoot / "tests")) { break; } - projectRoot = projectRoot.parent_path(); + + const fs::path parent = projectRoot.parent_path(); + if (parent.empty() || parent == projectRoot) { + break; + } + projectRoot = parent; } return projectRoot / "tests" / "test-data" / "test.mp4"; }