diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 802a56c..da1ea8b 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/CMakeLists.txt b/CMakeLists.txt index caa431d..f467430 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,8 +132,31 @@ 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 + 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.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)") +endif () + if (APPLE) file(GLOB LIB_SOURCE_MAC ${CMAKE_CURRENT_SOURCE_DIR}/src/*.mm) + list(FILTER LIB_SOURCE_MAC EXCLUDE REGEX ".*ccap_writer_apple.*") message(STATUS "ccap: Using Objective-C++ for macOS: ${LIB_SOURCE_MAC}") list(APPEND LIB_SOURCE ${LIB_SOURCE_MAC}) endif () @@ -207,6 +230,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/cli/args_parser.cpp b/cli/args_parser.cpp index b138d14..b2df486 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 0ff986d..fa6e270 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 eb06c61..9124a53 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 1355803..90bf3d1 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 0000000..6fa6ca1 --- /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 diff --git a/include/ccap_c.h b/include/ccap_c.h index 06602f8..8b6cdab 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 8f0288d..2df3f46 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 0000000..28da89f --- /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 (default 30fps; used for timestamp generation when timestampNs is 0) + 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 0000000..e5f0315 --- /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 (default 30fps) + 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 6e2b401..a893e3e 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_imp_linux.h b/src/ccap_imp_linux.h index 8b4cb9f..ec548de 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 ab4efe7..cdeb77a 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 6e4dd02..fe45ab6 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_utils.cpp b/src/ccap_utils.cpp index 378bee8..d3b7526 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.cpp b/src/ccap_writer.cpp new file mode 100644 index 0000000..d28bfb4 --- /dev/null +++ b/src/ccap_writer.cpp @@ -0,0 +1,100 @@ +/** + * @file ccap_writer.cpp + * @author wysaid (this@wysaid.org) + * @brief Video writer platform dispatch layer (pure C++). + * @date 2025-05 + */ + +#include "ccap_writer.h" + +#include "ccap_writer_imp.h" + +#ifdef CCAP_ENABLE_VIDEO_WRITER + +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() { + 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 0000000..ab63d37 --- /dev/null +++ b/src/ccap_writer_apple.mm @@ -0,0 +1,301 @@ +/** + * @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 +#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) { + 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()]; + + AVFileType fileType = AVFileTypeMPEG4; + if (config.container == VideoFormat::MOV) { + fileType = AVFileTypeQuickTimeMovie; + } + + // 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(fileType, pathStr, codecs[i])) { + m_actualCodec = cppCodecs[i]; + return true; + } + } + + reportError(ErrorCode::WriterOpenFailed, "Failed to create video writer with any supported codec"); + return false; + } + +private: + 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; + 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]; + + 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; + } + + [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) { + 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); + }]; + }); + 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)); + } + } + } + @catch (NSException* e) { + reportError(ErrorCode::WriterCloseFailed, "Exception during writer close: " + std::string(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; + + 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]) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + if (++waitMs > 2000) { + reportError(ErrorCode::WriterWriteFailed, "Writer input not ready after 2s, dropping frame"); + 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 (!convertFrameToNv12(frame, yBuf, uvBuf, yStride, uvStride)) { + reportError(ErrorCode::WriterWriteFailed, "Unsupported pixel format: " + std::to_string(static_cast(frame.pixelFormat))); + return false; + } + + // Create CVPixelBuffer + CVPixelBufferRef pixelBuffer = nullptr; + CVReturn ret = CVPixelBufferCreate(kCFAllocatorDefault, w, h, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + nullptr, &pixelBuffer); + if (ret != kCVReturnSuccess) { + reportError(ErrorCode::WriterWriteFailed, "CVPixelBufferCreate failed: " + std::to_string(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 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 / 1000000.0 * kTimeScale / 1000.0), kTimeScale); + } else { + double fps = m_config.frameRate > 0 ? m_config.frameRate : 30.0; + int64_t timeValue = static_cast(m_frameCount * (static_cast(kTimeScale) / fps)); + presentationTime = CMTimeMake(timeValue, kTimeScale); + } + + // Append pixel buffer via adaptor + BOOL success = [m_pixelBufferAdaptor appendPixelBuffer: pixelBuffer + withPresentationTime: presentationTime]; + CVPixelBufferRelease(pixelBuffer); + + if (!success) { + reportError(ErrorCode::WriterWriteFailed, "appendPixelBuffer failed: " + + std::string(m_assetWriter.error ? m_assetWriter.error.localizedDescription.UTF8String : "unknown")); + return false; + } + + m_frameCount++; + return true; + } + @catch (NSException* e) { + reportError(ErrorCode::WriterWriteFailed, "Exception during writeFrame: " + std::string(e.reason.UTF8String)); + return false; + } + } + +private: + AVAssetWriter* m_assetWriter; + AVAssetWriterInput* m_writerInput; + AVAssetWriterInputPixelBufferAdaptor* m_pixelBufferAdaptor; + BOOL m_sessionStarted; + std::atomic m_isOpened{false}; + 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 new file mode 100644 index 0000000..f5083f0 --- /dev/null +++ b/src/ccap_writer_c.cpp @@ -0,0 +1,115 @@ +/** + * @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)); + + uint64_t resolvedTimestamp = timestampNs > 0 ? timestampNs : frameInfo->timestamp; + return cppWriter->writeFrame(frame, resolvedTimestamp); + } 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 0000000..13e9f64 --- /dev/null +++ b/src/ccap_writer_imp.h @@ -0,0 +1,122 @@ +/** + * @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_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; + + 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; +}; + +/// 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 new file mode 100644 index 0000000..cf33549 --- /dev/null +++ b/src/ccap_writer_windows.cpp @@ -0,0 +1,308 @@ +/** + * @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_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 + +#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 { + +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); + if (!m_mfInitialized) { + CCAP_LOG_E("MFStartup failed: 0x%08lX\n", hr); + } + } + + ~WriterWindows() override { + close(); + if (m_mfInitialized) { + MFShutdown(); + } + } + + 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 + 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; + } + } + + reportError(ErrorCode::WriterOpenFailed, "Failed to create video writer with any supported codec"); + return false; + } + + void close() override { + if (!m_isOpened) return; + m_isOpened = false; + + if (m_sinkWriter) { + 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; + } + } + + bool isOpened() const override { + return m_isOpened; + } + + bool writeFrame(const VideoFrame& frame, uint64_t timestampNs) override { + if (!m_isOpened || !m_sinkWriter) 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; + } + + 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 (!convertFrameToNv12(frame, yBuf, uvBuf, yStride, uvStride)) { + reportError(ErrorCode::WriterWriteFailed, "Unsupported pixel format: " + std::to_string(static_cast(frame.pixelFormat))); + return false; + } + + // 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)) { + reportError(ErrorCode::WriterWriteFailed, "MFCreateSample failed"); + return false; + } + + IMFMediaBuffer* pBuffer = nullptr; + hr = MFCreateMemoryBuffer(totalSize, &pBuffer); + if (FAILED(hr)) { + reportError(ErrorCode::WriterWriteFailed, "MFCreateMemoryBuffer failed"); + pSample->Release(); + return false; + } + + BYTE* pData = nullptr; + hr = pBuffer->Lock(&pData, nullptr, nullptr); + if (FAILED(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(totalSize); + pSample->AddBuffer(pBuffer); + pBuffer->Release(); + + // Set timestamp (100ns units) + LONGLONG hnsTimestamp; + if (timestampNs > 0) { + 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); + + // 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)) { + reportError(ErrorCode::WriterWriteFailed, "WriteSample failed"); + return false; + } + + m_frameCount++; + return true; + } + +private: + 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, 2); + if (FAILED(hr)) return false; + + pAttributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); + pAttributes->SetUINT32(MF_SINK_WRITER_DISABLE_THROTTLING, TRUE); + + hr = MFCreateSinkWriterFromURL(filePath.c_str(), nullptr, pAttributes, &pWriter); + pAttributes->Release(); + + if (FAILED(hr)) { + 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)) { + pWriter->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); + MFSetAttributeSize(pOutputType, MF_MT_FRAME_SIZE, config.width, config.height); + + 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); + + DWORD streamIndex = 0; + hr = pWriter->AddStream(pOutputType, &streamIndex); + pOutputType->Release(); + if (FAILED(hr)) { + CCAP_LOG_E("AddStream failed: 0x%08lX\n", hr); + pWriter->Release(); + return false; + } + + // Configure input media type (raw NV12) + IMFMediaType* pInputType = nullptr; + hr = MFCreateMediaType(&pInputType); + if (FAILED(hr)) { + pWriter->Release(); + 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 = pWriter->SetInputMediaType(streamIndex, pInputType, nullptr); + pInputType->Release(); + + if (FAILED(hr)) { + CCAP_LOG_E("SetInputMediaType failed: 0x%08lX\n", hr); + pWriter->Release(); + return false; + } + + hr = pWriter->BeginWriting(); + if (FAILED(hr)) { + CCAP_LOG_E("BeginWriting failed: 0x%08lX\n", hr); + pWriter->Release(); + 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 }; +}; + +VideoWriter::Impl* createVideoWriterImpl() { + return new WriterWindows(); +} + +} // namespace ccap + +#endif // _WIN32 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d824293..3b2b9bb 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 0000000..ae29adb --- /dev/null +++ b/tests/test_video_writer.cpp @@ -0,0 +1,411 @@ +/** + * @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 +#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 (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); + } + } + } +}; + +// 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 { + 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); + } + } + } +}; + +// ---- 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 +#ifdef CCAP_ENABLE_FILE_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(); +#endif +} + +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.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.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_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); +}