diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 802a56c..6abaaec 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", @@ -1547,6 +1599,62 @@ }, "problemMatcher": "$msCompile" } + }, + { + "label": "Run ccap CLI --record camera (Debug)", + "type": "shell", + "command": "bash", + "args": [ + "-l", + "-c", + "( if [[ $(pwd) =~ ^/mnt ]]; then ./ccap.exe -w 1280 -H 720 --record ./camera_capture.mp4 --timeout 5 --preview; else ./ccap --record ./camera_capture.mp4 -w 1280 -H 720 --timeout 5 --preview; 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", "-w", "1280", "-H", "720", "--timeout", "5", "--preview"], + "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 -w 1280 -H 720 --record ./camera_capture.mp4 --timeout 5 --preview; else ./ccap --record ./camera_capture.mp4 -w 1280 -H 720 --timeout 5 --preview; 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", "-w", "1280", "-H", "720", "--timeout", "5", "--preview"], + "options": { + "cwd": "${workspaceFolder}/build/Release" + }, + "problemMatcher": "$msCompile" + } } ] } \ No newline at end of file diff --git a/BUILD_AND_INSTALL.md b/BUILD_AND_INSTALL.md index cefaad3..5a66acd 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/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/README.md b/README.md index 4622c0a..b259922 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 fe2158a..a88f22d 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 b138d14..2d4ac8b 100644 --- a/cli/args_parser.cpp +++ b/cli/args_parser.cpp @@ -172,6 +172,15 @@ 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" + << " Can be combined with --preview to preview while recording\n" + << " Supported formats: .mp4, .mov\n" + << "\n"; +#endif + #ifdef CCAP_CLI_WITH_GLFW std::cout << "Preview options:\n" << " -p, --preview enable window preview\n" @@ -227,6 +236,9 @@ void printUsage(const char* programName) { #ifdef CCAP_CLI_WITH_GLFW std::cout << " " << programName << " -d 0 --preview\n" << " " << programName << " -i /path/to/video.mp4 --preview\n"; +#ifdef CCAP_ENABLE_VIDEO_WRITER + std::cout << " " << programName << " -d 0 --preview --record output.mp4 --timeout 5\n"; +#endif #endif #ifdef CCAP_CLI_WITH_STB_IMAGE std::cout << " " << programName << " --convert input.yuv --yuv-format nv12 --yuv-width 1920 --yuv-height 1080 --convert-output output.jpg --image-format jpg\n"; @@ -344,6 +356,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") { @@ -391,6 +407,19 @@ CLIOptions parseArgs(int argc, char* argv[]) { if (i + 1 < argc) { opts.videoFilePath = argv[++i]; } + } else if (arg == "--record") { +#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/cli/args_parser.h b/cli/args_parser.h index 0ff986d..6d6882f 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"; @@ -84,6 +85,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..e2a02c3 100644 --- a/cli/ccap_cli.cpp +++ b/cli/ccap_cli.cpp @@ -82,25 +82,19 @@ 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); + } 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 @@ -125,7 +119,9 @@ int main(int argc, char* argv[]) { } // Check if we should just print info (no action specified) - bool hasAction = opts.enablePreview || opts.saveFrames || opts.captureCountSpecified || !opts.outputDir.empty(); + const bool hasCaptureAction = + opts.saveFrames || opts.captureCountSpecified || !opts.outputDir.empty() || !opts.recordVideoPath.empty(); + const bool hasAction = opts.enablePreview || hasCaptureAction; // Check if video file playback is requested but not supported on Linux #if defined(__linux__) || defined(__linux) || defined(linux) || defined(__gnu_linux__) @@ -186,7 +182,7 @@ int main(int argc, char* argv[]) { #endif // Default: capture mode - if (!opts.outputDir.empty() || opts.captureCountSpecified || opts.saveFrames) { + if (hasCaptureAction) { return ccap_cli::captureFrames(opts); } diff --git a/cli/ccap_cli_utils.cpp b/cli/ccap_cli_utils.cpp index 1355803..f7b9249 100644 --- a/cli/ccap_cli_utils.cpp +++ b/cli/ccap_cli_utils.cpp @@ -10,10 +10,15 @@ #include #include +#ifdef CCAP_ENABLE_VIDEO_WRITER +#include +#endif + #include #include #include #include +#include #include #include #include @@ -248,6 +253,124 @@ 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; +} + +#ifdef CCAP_ENABLE_VIDEO_WRITER +bool openVideoWriter(const CLIOptions& opts, bool isVideoMode, uint32_t width, uint32_t height, double frameRate, + std::unique_ptr& videoWriter) { + if (opts.recordVideoPath.empty()) { + return true; + } + + if (isVideoMode) { + std::cerr << "Warning: --record is not supported in video file mode. Ignoring." << std::endl; + return true; + } + + ccap::WriterConfig writerConfig; + writerConfig.width = width; + writerConfig.height = height; + writerConfig.frameRate = frameRate > 0.0 ? frameRate : 30.0; + + auto ext = std::filesystem::path(opts.recordVideoPath).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (ext == ".mov") { + writerConfig.container = ccap::VideoFormat::MOV; + } else if (ext == ".mp4" || ext.empty()) { + writerConfig.container = ccap::VideoFormat::MP4; + } else { + std::cerr << "Unsupported record file extension: " << ext + << " (expected .mp4 or .mov)" << std::endl; + return false; + } + + videoWriter = std::make_unique(); + if (!videoWriter->open(opts.recordVideoPath, writerConfig)) { + std::cerr << "Failed to open video writer for: " << opts.recordVideoPath << std::endl; + return false; + } + + if (ccap::infoLogEnabled()) { + std::cout << "Recording to: " << opts.recordVideoPath << std::endl; + } + return true; +} + +void closeVideoWriter(const CLIOptions& opts, std::unique_ptr& videoWriter) { + if (videoWriter && videoWriter->isOpened()) { + videoWriter->close(); + if (ccap::infoLogEnabled()) { + std::cout << "Video saved to: " << opts.recordVideoPath << std::endl; + } + } +} +#endif + } // namespace // ============================================================================ @@ -595,47 +718,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); } @@ -663,6 +752,16 @@ 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 (!openVideoWriter(opts, isVideoMode, static_cast(provider.get(ccap::PropertyName::Width)), + static_cast(provider.get(ccap::PropertyName::Height)), + provider.get(ccap::PropertyName::FrameRate), videoWriter)) { + return 1; + } +#endif + // Create output directory if saving frames bool shouldSave = opts.saveFrames && !opts.outputDir.empty(); if (shouldSave) { @@ -724,6 +823,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, frame->timestamp)) { + 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 +855,10 @@ int captureFrames(const CLIOptions& opts) { std::cout << "Captured " << capturedCount << " frame(s)." << std::endl; +#ifdef CCAP_ENABLE_VIDEO_WRITER + closeVideoWriter(opts, videoWriter); +#endif + if (timeoutOccurred) { return opts.timeoutExitCode; } @@ -1126,20 +1238,21 @@ void main() { int runPreview(const CLIOptions& opts) { auto backendOverride = makeWindowsCameraBackendOverride(opts, opts.videoFilePath.empty()); ccap::Provider provider; + const bool isVideoMode = !opts.videoFilePath.empty(); // Set capture parameters (only meaningful for camera mode) - if (opts.videoFilePath.empty()) { + if (!isVideoMode) { provider.set(ccap::PropertyName::Width, opts.width); provider.set(ccap::PropertyName::Height, opts.height); provider.set(ccap::PropertyName::FrameRate, opts.fps); } provider.set(ccap::PropertyName::FrameOrientation, ccap::FrameOrientation::BottomToTop); - provider.set(ccap::PropertyName::PixelFormatOutput, ccap::PixelFormat::RGBA32); + provider.set(ccap::PropertyName::PixelFormatOutput, ccap::PixelFormat::BGRA32); // Open device or video file bool opened = false; - if (!opts.videoFilePath.empty()) { + if (isVideoMode) { // Video file playback mode #if defined(CCAP_ENABLE_FILE_PLAYBACK) opened = provider.open(opts.videoFilePath, true); @@ -1148,45 +1261,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; @@ -1202,15 +1282,16 @@ int runPreview(const CLIOptions& opts) { } if (!opened || !provider.isStarted()) { - std::cerr << "Failed to open/start " << (opts.videoFilePath.empty() ? "camera device" : "video file") << "." << std::endl; + std::cerr << "Failed to open/start " << (isVideoMode ? "video file" : "camera device") << "." << std::endl; return 1; } // Get actual frame size int frameWidth = 0, frameHeight = 0; - if (auto frame = provider.grab(5000)) { - frameWidth = frame->width; - frameHeight = frame->height; + auto firstFrame = provider.grab(5000); + if (firstFrame) { + frameWidth = firstFrame->width; + frameHeight = firstFrame->height; if (ccap::infoLogEnabled()) { std::cout << "Camera resolution: " << frameWidth << "x" << frameHeight << std::endl; } @@ -1219,13 +1300,21 @@ int runPreview(const CLIOptions& opts) { return 1; } +#ifdef CCAP_ENABLE_VIDEO_WRITER + std::unique_ptr videoWriter; + if (!openVideoWriter(opts, isVideoMode, static_cast(frameWidth), static_cast(frameHeight), + provider.get(ccap::PropertyName::FrameRate), videoWriter)) { + return 1; + } +#endif + // Calculate window size - scale up if resolution is too low (below 480p) int windowWidth = frameWidth; int windowHeight = frameHeight; constexpr int MIN_DISPLAY_HEIGHT = 480; // Only scale up for video files, not for cameras - if (!opts.videoFilePath.empty() && frameHeight < MIN_DISPLAY_HEIGHT) { + if (isVideoMode && frameHeight < MIN_DISPLAY_HEIGHT) { double scale = static_cast(MIN_DISPLAY_HEIGHT) / frameHeight; windowWidth = static_cast(frameWidth * scale); windowHeight = static_cast(frameHeight * scale); @@ -1341,9 +1430,8 @@ int runPreview(const CLIOptions& opts) { glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); // Pre-allocate texture storage once - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, frameWidth, frameHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, frameWidth, frameHeight, 0, GL_BGRA, GL_UNSIGNED_BYTE, nullptr); - bool isVideoMode = !opts.videoFilePath.empty(); std::string sourceType = isVideoMode ? "video file" : "camera"; std::cout << "Preview started for " << sourceType << ". Press ESC or close window to exit." << std::endl; @@ -1356,6 +1444,7 @@ int runPreview(const CLIOptions& opts) { // Loop control for video playback int currentLoop = 0; int maxLoops = (isVideoMode && opts.enableLoop) ? (opts.loopCount > 0 ? opts.loopCount : -1) : 1; + auto pendingFrame = std::move(firstFrame); while (!glfwWindowShouldClose(window)) { if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) { @@ -1382,9 +1471,19 @@ int runPreview(const CLIOptions& opts) { glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texture); - if (auto frame = provider.grab(500)) { + auto frame = pendingFrame ? std::move(pendingFrame) : provider.grab(500); + if (frame) { // Update texture data efficiently using glTexSubImage2D - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, frameWidth, frameHeight, GL_RGBA, GL_UNSIGNED_BYTE, frame->data[0]); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, frameWidth, frameHeight, GL_BGRA, GL_UNSIGNED_BYTE, frame->data[0]); + +#ifdef CCAP_ENABLE_VIDEO_WRITER + if (videoWriter && videoWriter->isOpened()) { + if (!videoWriter->writeFrame(*frame, frame->timestamp)) { + std::cerr << "Warning: Failed to write frame " << frame->frameIndex << " to video." << std::endl; + } + } +#endif + ++capturedCount; } else { // If grab fails in file mode, video has ended @@ -1424,6 +1523,10 @@ int runPreview(const CLIOptions& opts) { glDeleteTextures(1, &texture); glfwTerminate(); +#ifdef CCAP_ENABLE_VIDEO_WRITER + closeVideoWriter(opts, videoWriter); +#endif + if (timeoutOccurred) { return opts.timeoutExitCode; } diff --git a/docs/content/c-interface.md b/docs/content/c-interface.md index 0a7505d..f846877 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 @@ -135,7 +138,42 @@ bool frame_callback(const CcapVideoFrame* frame, void* userData) { ccap_provider_set_new_frame_callback(provider, frame_callback, NULL); ``` -### 7. Cleanup Resources +### 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. + +```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); +} +``` + +### 8. Cleanup Resources ```c // Stop capture @@ -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 9a5d85e..1edbcda 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 6079d5d..5e91438 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 49b7362..3645739 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`, `NV12f`, `I420`, `I420f`, `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/examples/desktop/6-record_video.cpp b/examples/desktop/6-record_video.cpp new file mode 100644 index 0000000..3b78ccc --- /dev/null +++ b/examples/desktop/6-record_video.cpp @@ -0,0 +1,132 @@ +/** + * @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 +#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.empty() && 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; + using Clock = std::chrono::steady_clock; + Clock::time_point recordStart; + 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 (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"; + } + + 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/examples/desktop/6-record_video_c.c b/examples/desktop/6-record_video_c.c new file mode 100644 index 0000000..89d65fb --- /dev/null +++ b/examples/desktop/6-record_video_c.c @@ -0,0 +1,194 @@ +/** + * @file 6-record_video_c.c + * @brief Example: open a camera and record frames to a video file using ccap C interface + * @author wysaid (this@wysaid.org) + * @date 2025-05 + * + * Usage: + * ./6-record_video_c [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 "ccap_c.h" +#include "ccap_utils_c.h" +#include "utils/helper.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +#ifndef CCAP_ENABLE_VIDEO_WRITER + +int main(void) { + fprintf(stderr, "[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 "ccap_writer_c.h" + +// Error callback function +void error_callback(CcapErrorCode errorCode, const char* errorDescription, void* userData) { + (void)userData; + printf("Error - Code: %d, Description: %s\n", (int)errorCode, errorDescription); +} + +// Portable steady-clock timestamp in nanoseconds +static uint64_t steadyClockNowNs(void) { +#ifdef _WIN32 + // QueryPerformanceCounter is the Windows equivalent of CLOCK_MONOTONIC + LARGE_INTEGER freq, counter; + QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&counter); + return (uint64_t)(counter.QuadPart * 1000000000LL / freq.QuadPart); +#else + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000LL + (uint64_t)ts.tv_nsec; +#endif +} + +int main(int argc, char** argv) { + printf("ccap C Interface Video Recording Example\n"); + printf("Version: %s\n\n", ccap_get_version()); + + ExampleCommandLine commandLine = { 0 }; + initExampleCommandLine(&commandLine, argc, argv); + applyExampleCameraBackend(&commandLine); + + ccap_set_log_level(CCAP_LOG_LEVEL_VERBOSE); + ccap_set_error_callback(error_callback, NULL); + + // Determine output path + char outputPath[2048]; + if (commandLine.argc >= 2) { + strncpy(outputPath, commandLine.argv[1], sizeof(outputPath) - 1); + outputPath[sizeof(outputPath) - 1] = '\0'; + } else { + char exeDir[1024]; + if (commandLine.argc > 0 && commandLine.argv[0][0] != '.') { + strncpy(exeDir, commandLine.argv[0], sizeof(exeDir) - 1); + exeDir[sizeof(exeDir) - 1] = '\0'; + char* lastSlash = strrchr(exeDir, '/'); + if (!lastSlash) lastSlash = strrchr(exeDir, '\\'); + if (lastSlash) *lastSlash = '\0'; + } else { + if (getCurrentWorkingDirectory(exeDir, sizeof(exeDir)) != 0) { + strncpy(exeDir, ".", sizeof(exeDir) - 1); + } + } + snprintf(outputPath, sizeof(outputPath), "%s/camera_capture.mp4", exeDir); + } + + printf("Output video: %s\n", outputPath); + + // Open camera + CcapProvider* provider = ccap_provider_create(); + if (!provider) { + fprintf(stderr, "Failed to create provider\n"); + return -1; + } + + ccap_provider_set_property(provider, CCAP_PROPERTY_WIDTH, 1280); + ccap_provider_set_property(provider, CCAP_PROPERTY_HEIGHT, 720); + ccap_provider_set_property(provider, CCAP_PROPERTY_FRAME_RATE, 30.0); + + int deviceIndex = selectCamera(provider, &commandLine); + + if (!ccap_provider_open_by_index(provider, deviceIndex, true)) { + fprintf(stderr, "Failed to open camera!\n"); + ccap_provider_destroy(provider); + return -1; + } + + if (!ccap_provider_is_started(provider)) { + fprintf(stderr, "Failed to start camera!\n"); + ccap_provider_destroy(provider); + return -1; + } + + int realWidth = (int)ccap_provider_get_property(provider, CCAP_PROPERTY_WIDTH); + int realHeight = (int)ccap_provider_get_property(provider, CCAP_PROPERTY_HEIGHT); + double realFps = ccap_provider_get_property(provider, CCAP_PROPERTY_FRAME_RATE); + + printf("Camera started: %dx%d @ %.2f fps\n", realWidth, realHeight, realFps); + + // Configure and open video writer + CcapWriterConfig writerConfig; + memset(&writerConfig, 0, sizeof(writerConfig)); + writerConfig.codec = CCAP_VIDEO_CODEC_H264; + writerConfig.container = CCAP_VIDEO_FORMAT_MP4; + writerConfig.width = (uint32_t)realWidth; + writerConfig.height = (uint32_t)realHeight; + writerConfig.frameRate = realFps > 0.0 ? realFps : 30.0; + writerConfig.bitRate = 0; // auto bit rate based on resolution and codec (YouTube recommended) + + CcapVideoWriter* writer = ccap_video_writer_create(); + if (!writer) { + fprintf(stderr, "Failed to create video writer!\n"); + ccap_provider_destroy(provider); + return -1; + } + + if (!ccap_video_writer_open(writer, outputPath, &writerConfig)) { + fprintf(stderr, "Failed to open video writer!\n"); + ccap_video_writer_destroy(writer); + ccap_provider_destroy(provider); + return -1; + } + + // Record ~5 seconds + const int kMaxFrames = 150; + int recorded = 0; + uint64_t recordStartNs = 0; + printf("Recording %d frames (~5 seconds)...\n", kMaxFrames); + + while (recorded < kMaxFrames) { + CcapVideoFrame* frame = ccap_provider_grab(provider, 3000); + if (!frame) { + fprintf(stderr, "Timeout waiting for camera frame.\n"); + break; + } + + if (recorded == 0) { + recordStartNs = steadyClockNowNs(); + } + + uint64_t timestampNs = steadyClockNowNs() - recordStartNs; + + CcapVideoFrameInfo frameInfo; + if (ccap_video_frame_get_info(frame, &frameInfo)) { + if (!ccap_video_writer_write_frame(writer, &frameInfo, timestampNs)) { + fprintf(stderr, "Failed to write frame %d\n", recorded); + } + } + + ccap_video_frame_release(frame); + + if (++recorded % 30 == 0) { + printf(" Recorded %d/%d frames...\n", recorded, kMaxFrames); + } + } + + ccap_video_writer_close(writer); + ccap_video_writer_destroy(writer); + + ccap_provider_stop(provider); + ccap_provider_close(provider); + ccap_provider_destroy(provider); + + printf("Done! %d frames saved to: %s\n", recorded, outputPath); + 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..0494ebb --- /dev/null +++ b/include/ccap_writer.h @@ -0,0 +1,106 @@ +/** + * @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 { + H264, ///< H.264 / AVC (default, best compatibility and performance) + HEVC, ///< H.265 / HEVC (better compression, less compatible) +}; + +/** + * @brief Video container format. + */ +enum class VideoFormat { + MP4, ///< MP4 container + MOV, ///< MOV container +}; + +/** + * @brief Configuration for video writer. + */ +struct WriterConfig { + VideoCodec codec = VideoCodec::H264; ///< Default codec; auto-fallback to HEVC if H.264 is 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 = 0; ///< Target bit rate in bits/s; 0 = auto (YouTube official recommended bitrates) +}; + +/** + * @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.) + * @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); + + /// 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). + /// Only meaningful after `open()` succeeds. + 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..815681c --- /dev/null +++ b/include/ccap_writer_c.h @@ -0,0 +1,122 @@ +/** + * @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_H264 = 0, ///< H.264 / AVC (default, best compatibility) + CCAP_VIDEO_CODEC_HEVC = 1, ///< H.265 / HEVC (better compression, less compatible) +} CcapVideoCodec; + +/** @brief Video container format */ +typedef enum { + CCAP_VIDEO_FORMAT_MP4 = 0, + CCAP_VIDEO_FORMAT_MOV = 1, +} CcapVideoFormat; + +/* ========== Data Structures ========== */ + +/** + * @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; 0 lets open() normalize to 30fps + uint64_t bitRate; ///< Target bit rate in bits/s (0 = auto, YouTube recommended bitrates) +} 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_H264, CCAP_VIDEO_FORMAT_MP4, 0u, 0u, 30.0, 0ULL } + +/* ========== 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. 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); + +#ifdef __cplusplus +} +#endif + +#endif /* CCAP_WRITER_C_H */ diff --git a/skills/ccap/SKILL.md b/skills/ccap/SKILL.md index bd08f43..dfcb775 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_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_file_reader_windows.cpp b/src/ccap_file_reader_windows.cpp index 78c28a5..2d1b553 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(); } } diff --git a/src/ccap_file_reader_windows.h b/src/ccap_file_reader_windows.h index e5a622a..8f98238 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_imp.h b/src/ccap_imp.h index 7cc3956..d196ef2 100644 --- a/src/ccap_imp.h +++ b/src/ccap_imp.h @@ -103,6 +103,7 @@ class ProviderImp { protected: void newFrameAvailable(std::shared_ptr frame); + /// Get a free frame from the pool. Never returns null — allocates a new frame if needed. std::shared_ptr getFreeFrame(); protected: 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..818b7eb --- /dev/null +++ b/src/ccap_writer.cpp @@ -0,0 +1,110 @@ +/** + * @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; + } + 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); +} + +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) { + reportError(ErrorCode::WriterNotOpened, "VideoWriter not available on this platform"); + 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..36ff24d --- /dev/null +++ b/src/ccap_writer_apple.mm @@ -0,0 +1,305 @@ +/** + * @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 = static_cast(effectiveBitRate(m_config)); + 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; + + AVAssetWriter* assetWriter = m_assetWriter; + AVAssetWriterInput* writerInput = m_writerInput; + + @try { + if (writerInput) { + [writerInput markAsFinished]; + } + if (assetWriter) { + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + [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)); + } + } + } + @catch (NSException* e) { + reportError(ErrorCode::WriterCloseFailed, "Exception during writer close: " + std::string(e.reason.UTF8String)); + } + + m_pixelBufferAdaptor = nil; + m_writerInput = nil; + m_assetWriter = nil; + m_sessionStarted = NO; + m_frameCount = 0; + 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..63df94e --- /dev/null +++ b/src/ccap_writer_c.cpp @@ -0,0 +1,129 @@ +/** + * @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) return; + try { + delete reinterpret_cast(writer); + } catch (...) { + // Never throw across C ABI boundary. + } +} + +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) 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; + try { + return reinterpret_cast(writer)->isOpened(); + } catch (...) { + return false; + } +} + +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]; + } + 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 = resolvedTimestamp; + frame.frameIndex = frameInfo->frameIndex; + frame.orientation = static_cast(static_cast(frameInfo->orientation)); + + return cppWriter->writeFrame(frame, resolvedTimestamp); + } catch (...) { + return false; + } +} + +CcapVideoCodec ccap_video_writer_actual_codec(const CcapVideoWriter* writer) { + if (!writer) return 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" + +#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..99b022c --- /dev/null +++ b/src/ccap_writer_imp.h @@ -0,0 +1,224 @@ +/** + * @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(); + +/// Compute auto bit rate based on resolution and frame rate. +/// +/// Reference: YouTube official recommended bitrates for H.264 encoding. +/// https://support.google.com/youtube/answer/2853702 +/// +/// YouTube H.264 reference points (Mbps): +/// 720p @30fps → 7.5 720p @60fps → 9.0 +/// 1080p @30fps → 10 1080p @60fps → 12 +/// 1440p @30fps → 15 1440p @60fps → 24 +/// 2160p @30fps → 30 2160p @60fps → 35 +/// +/// For resolutions below 720p, the 720p rate is used as floor. +/// For resolutions between reference points, bit rate is linearly interpolated by pixel count. +/// For resolutions above 4K, bit rate is extrapolated by pixel count. +/// For HEVC, bit rate is scaled down to ~60% of H.264 (HEVC achieves similar quality at lower bit rate). +inline uint64_t computeAutoBitRate(uint32_t width, uint32_t height, double frameRate, VideoCodec codec) { + const double kMbps = 1'000'000.0; + const double fps = (frameRate > 0.0) ? frameRate : 30.0; + const bool is60fps = fps > 45.0; + + // YouTube H.264 reference data points: (pixelCount, bitrateInMbps) + struct RefPoint { double pixels; double bitrateMbps; }; + static const RefPoint refs30[] = { + {1280 * 720, 7.5}, + {1920 * 1080, 10.0}, + {2560 * 1440, 15.0}, + {3840 * 2160, 30.0}, + }; + static const RefPoint refs60[] = { + {1280 * 720, 9.0}, + {1920 * 1080, 12.0}, + {2560 * 1440, 24.0}, + {3840 * 2160, 35.0}, + }; + + const RefPoint* refs = is60fps ? refs60 : refs30; + const int refCount = 4; + const double pixels = static_cast(width) * height; + double bitrateMbps; + + if (pixels <= refs[0].pixels) { + // Below 720p: use 720p floor + bitrateMbps = refs[0].bitrateMbps; + } else if (pixels >= refs[refCount - 1].pixels) { + // Above 4K: extrapolate using slope of the last two reference points + const auto& a = refs[refCount - 2]; + const auto& b = refs[refCount - 1]; + double slope = (b.bitrateMbps - a.bitrateMbps) / (b.pixels - a.pixels); + bitrateMbps = b.bitrateMbps + slope * (pixels - b.pixels); + } else { + // Between reference points: linear interpolation by pixel count + int i = 0; + while (i < refCount - 1 && pixels > refs[i + 1].pixels) i++; + const auto& lo = refs[i]; + const auto& hi = refs[i + 1]; + double t = (pixels - lo.pixels) / (hi.pixels - lo.pixels); + bitrateMbps = lo.bitrateMbps + t * (hi.bitrateMbps - lo.bitrateMbps); + } + + // Scale for non-standard frame rates between 30 and 60 + if (!is60fps && fps > 30.0) { + const auto& r30 = refs30; + const auto& r60 = refs60; + // Average ratio across reference points + double ratio = 0; + for (int i = 0; i < refCount; i++) ratio += r60[i].bitrateMbps / r30[i].bitrateMbps; + ratio /= refCount; // ~1.27 + double t = (fps - 30.0) / 30.0; + bitrateMbps *= (1.0 + t * (ratio - 1.0)); + } + + // HEVC achieves similar visual quality at ~60% of H.264 bit rate + if (codec == VideoCodec::HEVC) { + bitrateMbps *= 0.6; + } + + return static_cast(bitrateMbps * kMbps); +} + +/// Resolve effective bit rate: use user value if set, otherwise compute auto. +inline uint64_t effectiveBitRate(const WriterConfig& config) { + if (config.bitRate > 0) return config.bitRate; + return computeAutoBitRate(config.width, config.height, config.frameRate, config.codec); +} + +// ---- 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, + 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 + 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]; + 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); + 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; + + 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] + 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] + 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] + 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][srcRow * frame.stride[1] + x]; + uvBuf[y * uvStride + x * 2 + 1] = frame.data[2][srcRow * 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, 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, orientation); + 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..30f8f0c --- /dev/null +++ b/src/ccap_writer_windows.cpp @@ -0,0 +1,327 @@ +/** + * @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 +#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 { + +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) { + 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: " + formatHRESULT(hr)); + } + m_sinkWriter->Release(); + m_sinkWriter = nullptr; + } + + m_streamIndex = 0; + m_frameCount = 0; + 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_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(effectiveBitRate(config))); + 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..98ad81a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -435,6 +435,61 @@ 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 + ) + + 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 + ) + + 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 +515,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_ccap_cli.cpp b/tests/test_ccap_cli.cpp index 7482dd5..8a6884b 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; } }; @@ -966,6 +979,36 @@ TEST_F(CCAPCLIDeviceTest, CaptureWithTimeout) { ASSERT_EQ(imageCount, 1) << "Expected 1 image file, found " << imageCount; } +#ifdef CCAP_ENABLE_VIDEO_WRITER +TEST_F(CCAPCLIDeviceTest, RecordWithTimeoutRunsCaptureMode) { + fs::path outputVideoPath = testOutputDir / "record_should_enter_capture_mode.avi"; + std::string cmd = "-d 0 --record \"" + outputVideoPath.string() + "\" --timeout 1"; + + auto result = runCLI(cmd); + + // `.avi` is intentionally unsupported: this should fail in capture pipeline, + // not fall back to camera info printing mode. + EXPECT_NE(result.exitCode, 0); + EXPECT_THAT(result.output, testing::HasSubstr("Unsupported record file extension")); + EXPECT_THAT(result.output, testing::Not(testing::HasSubstr("===== Device ["))); + EXPECT_THAT(result.output, testing::Not(testing::HasSubstr("Supported resolutions:"))); +} + +#ifdef CCAP_CLI_WITH_GLFW +TEST_F(CCAPCLIDeviceTest, PreviewAndRecordWithUnsupportedExtensionUsesPreviewPath) { + fs::path outputVideoPath = testOutputDir / "preview_record_should_use_preview_path.avi"; + std::string cmd = "-d 0 --preview --record \"" + outputVideoPath.string() + "\" --timeout 1"; + + auto result = runCLI(cmd); + + EXPECT_NE(result.exitCode, 0); + EXPECT_THAT(result.output, testing::HasSubstr("Unsupported record file extension")); + EXPECT_THAT(result.output, testing::Not(testing::HasSubstr("===== Device ["))); + EXPECT_THAT(result.output, testing::Not(testing::HasSubstr("Supported resolutions:"))); +} +#endif +#endif + TEST_F(CCAPCLIDeviceTest, CaptureInvalidDevice) { std::string outputDir = testOutputDir.string(); // Try to capture from device index 999 (should fail or fallback to default) diff --git a/tests/test_cli_args_parser.cpp b/tests/test_cli_args_parser.cpp index 346db07..197e387 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"; @@ -72,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 new file mode 100644 index 0000000..6aa1a2b --- /dev/null +++ b/tests/test_video_writer.cpp @@ -0,0 +1,859 @@ +/** + * @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 "ccap_writer_imp.h" +#include +#include +#include +#include +#include +#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) + return true; +#else + return false; +#endif +} + +// 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; +} + +class VideoWriterTestBase : 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& path : m_outputPaths) { + fs::remove(path, ec); + ec.clear(); + } + } + + fs::path makeTestOutputPath(std::string_view name, std::string_view extension = ".mp4") { + const auto* info = ::testing::UnitTest::GetInstance()->current_test_info(); + + 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) { + 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(makeTestOutputPath("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 = makeTestOutputPath("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 = makeTestOutputPath("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, frame.timestamp); + 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, 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, 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; + 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 = makeTestOutputPath("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; + config.width = 320; + config.height = 240; + config.frameRate = 30.0; + config.bitRate = 2000000; + config.container = ccap::VideoFormat::MOV; + + fs::path outputPath = makeTestOutputPath("mov_container", ".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 = makeTestOutputPath("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 = 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{}; + 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; + config.width = 640; + config.height = 480; + config.frameRate = 25.0; + config.bitRate = 3000000; + + fs::path outputPath = makeTestOutputPath("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 = CCAP_WRITER_CONFIG_INIT; + config.width = 320; + config.height = 240; + config.bitRate = 2000000; + + 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)); + + // 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, 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 = CCAP_WRITER_CONFIG_INIT; + config.codec = CCAP_VIDEO_CODEC_H264; + config.width = static_cast(w); + config.height = static_cast(h); + config.bitRate = 8'000'000; + + fs::path outputPath = makeTestOutputPath("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(); + while (true) { + if (fs::exists(projectRoot / "CMakeLists.txt") && fs::exists(projectRoot / "tests")) { + break; + } + + const fs::path parent = projectRoot.parent_path(); + if (parent.empty() || parent == projectRoot) { + break; + } + projectRoot = parent; + } + 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 = makeTestOutputPath("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 = makeTestOutputPath("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); + + CcapWriterConfig config = CCAP_WRITER_CONFIG_INIT; + config.codec = CCAP_VIDEO_CODEC_H264; + config.width = 320; + config.height = 240; + 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); +}