From 04f9ccbe8343706618395e42ffcf41343c4652f3 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Sat, 19 Sep 2026 15:10:01 -0500 Subject: [PATCH] Discover Linux camera modes through native V4L2 queries --- bindings/python/openshot.i | 1 + src/CameraCaptureReader.cpp | 29 ++++++ src/CameraCaptureReader.h | 16 ++++ src/CameraCaptureV4L2.h | 108 +++++++++++++++++++++ tests/CameraCaptureReader.cpp | 173 ++++++++++++++++++++++++++++++++++ 5 files changed, 327 insertions(+) create mode 100644 src/CameraCaptureV4L2.h diff --git a/bindings/python/openshot.i b/bindings/python/openshot.i index da0fde570..6602b465c 100644 --- a/bindings/python/openshot.i +++ b/bindings/python/openshot.i @@ -486,6 +486,7 @@ static int openshot_swig_is_qwidget(PyObject *obj) { %include "AudioRecorder.h" %include "AudioWaveformer.h" %include "CameraCaptureReader.h" +%template(CameraCaptureModeVector) std::vector; %include "CacheBase.h" %include "CacheDisk.h" %include "CacheMemory.h" diff --git a/src/CameraCaptureReader.cpp b/src/CameraCaptureReader.cpp index 35f3b71c6..4aeeee9d7 100644 --- a/src/CameraCaptureReader.cpp +++ b/src/CameraCaptureReader.cpp @@ -12,6 +12,13 @@ #include "CameraCaptureReader.h" +#if defined(__linux__) +#include "CameraCaptureV4L2.h" +#include +#include +#include +#endif + #include #include @@ -217,6 +224,28 @@ AudioDeviceList CameraCaptureReader::GetDeviceNames(CameraCaptureBackend backend return devices; } +std::vector CameraCaptureReader::GetDeviceModes( + const std::string& device, CameraCaptureBackend backend) +{ + if (backend == CAMERA_CAPTURE_AUTO) backend = DefaultBackend(); +#if defined(__linux__) + if (backend == CAMERA_CAPTURE_V4L2) { + const int fd = open(device.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) throw InvalidFile("Unable to open camera for mode discovery.", device); + struct DeviceHandle { + int fd; + ~DeviceHandle() { close(fd); } + } handle{fd}; + return detail::EnumerateCameraModes([fd](unsigned long request, void* value) { + return ioctl(fd, request, value); + }); + } +#else + (void) device; +#endif + return {}; +} + void CameraCaptureReader::ValidateSettings() const { if (!IsBackendSupported(settings.backend)) { diff --git a/src/CameraCaptureReader.h b/src/CameraCaptureReader.h index f9bea3ca3..b19fb4384 100644 --- a/src/CameraCaptureReader.h +++ b/src/CameraCaptureReader.h @@ -17,6 +17,7 @@ #include "ScreenCaptureReader.h" #include +#include namespace openshot { @@ -28,6 +29,15 @@ namespace openshot CAMERA_CAPTURE_MAC_AVFOUNDATION = 3 }; + /// A supported capture combination, with an FFmpeg input format name. + struct CameraCaptureMode + { + int width = 0; + int height = 0; + openshot::Fraction fps = openshot::Fraction(0, 1); + std::string input_format; + }; + struct CameraCaptureSettings { CameraCaptureBackend backend = CAMERA_CAPTURE_AUTO; @@ -61,6 +71,12 @@ namespace openshot static CameraCaptureBackend DefaultBackend(); static AudioDeviceList GetDeviceNames(CameraCaptureBackend backend = CAMERA_CAPTURE_AUTO); + /// Query Linux V4L2 modes without starting capture. Discrete modes are exhaustive; + /// continuous/stepwise ranges report their endpoints. Unknown formats are omitted. + /// Other backends return an empty list. Device access/query errors throw. + static std::vector GetDeviceModes( + const std::string& device, CameraCaptureBackend backend = CAMERA_CAPTURE_AUTO); + private: void ValidateSettings() const; ScreenCaptureSettings ToDeviceSettings() const; diff --git a/src/CameraCaptureV4L2.h b/src/CameraCaptureV4L2.h new file mode 100644 index 000000000..a5dd64cae --- /dev/null +++ b/src/CameraCaptureV4L2.h @@ -0,0 +1,108 @@ +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// SPDX-License-Identifier: LGPL-3.0-or-later + +#ifndef OPENSHOT_CAMERA_CAPTURE_V4L2_H +#define OPENSHOT_CAMERA_CAPTURE_V4L2_H + +// Private, Linux-only enumeration helper. Injecting ioctl allows deterministic +// tests without opening a camera or changing its capture configuration. +#if defined(__linux__) +#include "CameraCaptureReader.h" +#include +#include +#include +#include +#include + +namespace openshot { namespace detail { +using CameraIoctl = std::function; + +inline bool CameraQuery(const CameraIoctl& query, unsigned long request, void* value) +{ + int result; + do { result = query(request, value); } while (result < 0 && errno == EINTR); + if (result >= 0) return true; + if (errno == EINVAL || errno == ENOTTY) return false; + throw std::runtime_error("Unable to enumerate V4L2 camera modes (errno " + std::to_string(errno) + ")."); +} + +inline std::string CameraInputFormat(unsigned int format) +{ + switch (format) { + case V4L2_PIX_FMT_MJPEG: return "mjpeg"; + case V4L2_PIX_FMT_JPEG: return "mjpeg"; + case V4L2_PIX_FMT_H264: return "h264"; + case V4L2_PIX_FMT_YUYV: return "yuyv422"; + case V4L2_PIX_FMT_UYVY: return "uyvy422"; + case V4L2_PIX_FMT_NV12: return "nv12"; + case V4L2_PIX_FMT_YUV420: return "yuv420p"; + case V4L2_PIX_FMT_RGB24: return "rgb24"; + case V4L2_PIX_FMT_BGR24: return "bgr24"; + case V4L2_PIX_FMT_GREY: return "gray"; + default: return ""; // Never pass an unrecognized FOURCC to FFmpeg. + } +} + +inline std::vector EnumerateCameraModes(const CameraIoctl& query) +{ + std::vector modes; + auto add = [&](unsigned int width, unsigned int height, const std::string& format, v4l2_fract interval) { + if (!width || !height || width > INT_MAX || height > INT_MAX || + !interval.numerator || !interval.denominator || + interval.numerator > INT_MAX || interval.denominator > INT_MAX) return; + CameraCaptureMode mode; + mode.width = width; + mode.height = height; + mode.input_format = format; + mode.fps = Fraction(interval.denominator, interval.numerator); + mode.fps.Reduce(); + for (const auto& existing : modes) + if (existing.width == mode.width && existing.height == mode.height && + existing.input_format == mode.input_format && existing.fps.num == mode.fps.num && + existing.fps.den == mode.fps.den) return; + modes.push_back(mode); + }; + for (unsigned int f = 0; ; ++f) { + v4l2_fmtdesc format = {}; + format.index = f; + format.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + if (!CameraQuery(query, VIDIOC_ENUM_FMT, &format)) break; + const auto input_format = CameraInputFormat(format.pixelformat); + if (input_format.empty()) continue; + for (unsigned int s = 0; ; ++s) { + v4l2_frmsizeenum size = {}; + size.index = s; + size.pixel_format = format.pixelformat; + if (!CameraQuery(query, VIDIOC_ENUM_FRAMESIZES, &size)) break; + std::vector> sizes; + if (size.type == V4L2_FRMSIZE_TYPE_DISCRETE) + sizes.emplace_back(size.discrete.width, size.discrete.height); + else if (size.type == V4L2_FRMSIZE_TYPE_STEPWISE || size.type == V4L2_FRMSIZE_TYPE_CONTINUOUS) { + sizes.emplace_back(size.stepwise.min_width, size.stepwise.min_height); + sizes.emplace_back(size.stepwise.max_width, size.stepwise.max_height); + } + for (const auto& dimensions : sizes) { + for (unsigned int i = 0; ; ++i) { + v4l2_frmivalenum interval = {}; + interval.index = i; + interval.pixel_format = format.pixelformat; + interval.width = dimensions.first; + interval.height = dimensions.second; + if (!CameraQuery(query, VIDIOC_ENUM_FRAMEINTERVALS, &interval)) break; + if (interval.type == V4L2_FRMIVAL_TYPE_DISCRETE) + add(interval.width, interval.height, input_format, interval.discrete); + else if (interval.type == V4L2_FRMIVAL_TYPE_STEPWISE || interval.type == V4L2_FRMIVAL_TYPE_CONTINUOUS) { + add(interval.width, interval.height, input_format, interval.stepwise.min); + add(interval.width, interval.height, input_format, interval.stepwise.max); + } + if (interval.type != V4L2_FRMIVAL_TYPE_DISCRETE) break; + } + } + if (size.type != V4L2_FRMSIZE_TYPE_DISCRETE) break; + } + } + return modes; +} +}} +#endif +#endif diff --git a/tests/CameraCaptureReader.cpp b/tests/CameraCaptureReader.cpp index cc19ce099..5eb8335d4 100644 --- a/tests/CameraCaptureReader.cpp +++ b/tests/CameraCaptureReader.cpp @@ -14,8 +14,10 @@ #include "CameraCaptureReader.h" #include "Exceptions.h" +#include "Frame.h" #include +#include #include #include #include @@ -186,3 +188,174 @@ TEST_CASE("Camera capture default backend follows platform", "[libopenshot][came CHECK(CameraCaptureReader::DefaultBackend() == CAMERA_CAPTURE_MAC_AVFOUNDATION); #endif } + +#if defined(__linux__) +#include "CameraCaptureV4L2.h" + +namespace { +struct ModeDriver { + bool interrupted = false; + bool fail = false; + bool ranges = false; + bool invalid = false; + std::vector formats{V4L2_PIX_FMT_YUYV, V4L2_PIX_FMT_MJPEG, v4l2_fourcc('B','A','D','!')}; + int Query(unsigned long request, void* arg) { + if (!interrupted) { interrupted = true; errno = EINTR; return -1; } + if (fail) { errno = EIO; return -1; } + if (request == VIDIOC_ENUM_FMT) { + auto& f = *static_cast(arg); + CHECK(f.type == V4L2_BUF_TYPE_VIDEO_CAPTURE); + CHECK(f.reserved[0] == 0); + if (f.index < formats.size()) { f.pixelformat = formats[f.index]; return 0; } + } else if (request == VIDIOC_ENUM_FRAMESIZES) { + auto& s = *static_cast(arg); + REQUIRE(s.pixel_format != v4l2_fourcc('B','A','D','!')); + if (s.index == 0) { + if (ranges) { + s.type = V4L2_FRMSIZE_TYPE_STEPWISE; + s.stepwise = {640, 1920, 16, 480, 1080, 4}; + } else { + s.type = V4L2_FRMSIZE_TYPE_DISCRETE; + s.discrete = {1920, 1080}; + } + return 0; + } + } else if (request == VIDIOC_ENUM_FRAMEINTERVALS) { + auto& i = *static_cast(arg); + CHECK((i.width == 1920 || (ranges && i.width == 640))); + CHECK((i.height == 1080 || (ranges && i.height == 480))); + if (ranges && i.index == 0) { + i.type = V4L2_FRMIVAL_TYPE_CONTINUOUS; + i.stepwise.min = {1, 30}; + i.stepwise.max = {1, 5}; + return 0; + } + if (!ranges) { + i.type = V4L2_FRMIVAL_TYPE_DISCRETE; + if (i.pixel_format == V4L2_PIX_FMT_YUYV && i.index == 0) { + i.discrete = {1, 5}; return 0; + } + if (i.pixel_format == V4L2_PIX_FMT_MJPEG && i.index < 4) { + const v4l2_fract intervals[] = {{1,30}, {1001,30000}, {2,15}, {2,60}}; + i.discrete = invalid ? v4l2_fract{0, 30} : intervals[i.index]; + return 0; + } + } + } else { + FAIL("Discovery must only enumerate formats, sizes, and intervals"); + } + errno = EINVAL; + return -1; + } + std::vector Modes() { + return openshot::detail::EnumerateCameraModes([&](unsigned long r, void* v) { return Query(r, v); }); + } +}; +} + +TEST_CASE("V4L2 modes preserve format and exact frame rates", "[libopenshot][cameracapturereader][modes]") +{ + ModeDriver driver; + const auto modes = driver.Modes(); + REQUIRE(modes.size() == 4); // Unknown FOURCC skipped, equivalent 30 fps deduplicated. + CHECK(driver.interrupted); // Initial EINTR is retried. + CHECK(modes[0].input_format == "yuyv422"); + CHECK(modes[0].fps.num == 5); + CHECK(modes[1].input_format == "mjpeg"); + CHECK(modes[1].fps.num == 30); + CHECK(modes[1].width == 1920); + CHECK(modes[1].height == 1080); + CHECK(modes[2].fps.num == 30000); + CHECK(modes[2].fps.den == 1001); + CHECK(modes[3].fps.num == 15); + CHECK(modes[3].fps.den == 2); +} + +TEST_CASE("V4L2 mode discovery handles unavailable and invalid data", "[libopenshot][cameracapturereader][modes]") +{ + ModeDriver driver; + SECTION("No formats") { driver.formats.clear(); CHECK(driver.Modes().empty()); } + SECTION("Invalid intervals are not invented as 30 fps") { + driver.invalid = true; + const auto modes = driver.Modes(); + REQUIRE(modes.size() == 1); + CHECK(modes[0].fps.num == 5); + } + SECTION("Device failure is reported") { driver.fail = true; CHECK_THROWS_AS(driver.Modes(), std::runtime_error); } + SECTION("Unsupported ioctl") { + CHECK(openshot::detail::EnumerateCameraModes([](unsigned long, void*) { errno = ENOTTY; return -1; }).empty()); + } + SECTION("Nonexistent device") { + CHECK_THROWS_AS(CameraCaptureReader::GetDeviceModes("/dev/null/openshot-camera"), InvalidFile); + } + SECTION("Non-camera device") { CHECK(CameraCaptureReader::GetDeviceModes("/dev/null").empty()); } +} + +TEST_CASE("V4L2 range endpoints remain valid mode combinations", "[libopenshot][cameracapturereader][modes]") +{ + ModeDriver driver; + driver.ranges = true; + const auto modes = driver.Modes(); + REQUIRE(modes.size() == 8); + CHECK(modes[0].width == 640); + CHECK(modes[0].height == 480); + CHECK(modes[0].fps.num == 30); + CHECK(modes[1].fps.num == 5); + CHECK(modes[2].width == 1920); + CHECK(modes[2].height == 1080); + CHECK(modes[2].fps.num == 30); +} +#endif + +TEST_CASE("Camera mode discovery leaves other backends untouched", "[libopenshot][cameracapturereader][modes]") +{ + CHECK(CameraCaptureReader::GetDeviceModes("unused", CAMERA_CAPTURE_WINDOWS_DSHOW).empty()); + CHECK(CameraCaptureReader::GetDeviceModes("unused", CAMERA_CAPTURE_MAC_AVFOUNDATION).empty()); +} + +#if defined(__linux__) +// Opt in with OPENSHOT_TEST_CAMERA=/dev/video0. Requires a camera advertising +// 1080p30 MJPEG, adequate lighting, and exclusive access for streaming. +TEST_CASE("V4L2 hardware captures advertised 1080p30 MJPEG", "[libopenshot][cameracapturereader][hardware]") +{ + const char* device = std::getenv("OPENSHOT_TEST_CAMERA"); + if (!device || !*device) { + SUCCEED("Set OPENSHOT_TEST_CAMERA to enable the live camera test"); + return; + } + const auto modes = CameraCaptureReader::GetDeviceModes(device); + bool supported = false; + for (const auto& mode : modes) + if (mode.width == 1920 && mode.height == 1080 && mode.input_format == "mjpeg" && + mode.fps.num == 30 && mode.fps.den == 1) supported = true; + REQUIRE(supported); + CameraCaptureSettings settings; + settings.device = device; + settings.width = 1920; + settings.height = 1080; + settings.fps = Fraction(30, 1); + settings.options["input_format"] = "mjpeg"; + CameraCaptureReader reader(settings); + reader.Open(); + REQUIRE(reader.info.width == 1920); + REQUIRE(reader.info.height == 1080); + REQUIRE(reader.info.fps.num == 30); + REQUIRE(reader.info.fps.den == 1); + // Discovery while streaming must not change the camera configuration. + CHECK(CameraCaptureReader::GetDeviceModes(device).size() == modes.size()); + double first = 0, previous = 0; + for (int i = 1; i <= 60; ++i) { + const auto frame = reader.GetFrame(i); + REQUIRE(frame != nullptr); + if (i == 1) first = frame->capture_timestamp; + else REQUIRE(frame->capture_timestamp > previous); + previous = frame->capture_timestamp; + } + const double measured_fps = 59.0 / (previous - first); + INFO("Measured source fps: " << measured_fps); + CHECK(measured_fps > 27.0); + CHECK(measured_fps < 33.0); + reader.Close(); + CHECK_FALSE(reader.IsOpen()); +} +#endif