Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bindings/python/openshot.i
Original file line number Diff line number Diff line change
Expand Up @@ -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<openshot::CameraCaptureMode>;
%include "CacheBase.h"
%include "CacheDisk.h"
%include "CacheMemory.h"
Expand Down
29 changes: 29 additions & 0 deletions src/CameraCaptureReader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@

#include "CameraCaptureReader.h"

#if defined(__linux__)
#include "CameraCaptureV4L2.h"
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#endif

#include <cstdlib>
#include <string>

Expand Down Expand Up @@ -217,6 +224,28 @@ AudioDeviceList CameraCaptureReader::GetDeviceNames(CameraCaptureBackend backend
return devices;
}

std::vector<CameraCaptureMode> 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)) {
Expand Down
16 changes: 16 additions & 0 deletions src/CameraCaptureReader.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include "ScreenCaptureReader.h"

#include <memory>
#include <vector>

namespace openshot
{
Expand All @@ -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;
Expand Down Expand Up @@ -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<CameraCaptureMode> GetDeviceModes(
const std::string& device, CameraCaptureBackend backend = CAMERA_CAPTURE_AUTO);

private:
void ValidateSettings() const;
ScreenCaptureSettings ToDeviceSettings() const;
Expand Down
108 changes: 108 additions & 0 deletions src/CameraCaptureV4L2.h
Original file line number Diff line number Diff line change
@@ -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 <linux/videodev2.h>
#include <cerrno>
#include <climits>
#include <functional>
#include <stdexcept>

namespace openshot { namespace detail {
using CameraIoctl = std::function<int(unsigned long, void*)>;

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<CameraCaptureMode> EnumerateCameraModes(const CameraIoctl& query)
{
std::vector<CameraCaptureMode> 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<std::pair<unsigned int, unsigned int>> 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
173 changes: 173 additions & 0 deletions tests/CameraCaptureReader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@

#include "CameraCaptureReader.h"
#include "Exceptions.h"
#include "Frame.h"

#include <chrono>
#include <cstdlib>
#include <condition_variable>
#include <future>
#include <mutex>
Expand Down Expand Up @@ -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<unsigned int> 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<v4l2_fmtdesc*>(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<v4l2_frmsizeenum*>(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<v4l2_frmivalenum*>(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<CameraCaptureMode> 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
Loading