diff --git a/CMakeLists.txt b/CMakeLists.txt
index 634784791..9a812bb7b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -24,8 +24,8 @@ For more information, please visit .
set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/Modules")
################ PROJECT VERSION ####################
-set(PROJECT_VERSION_FULL "0.7.0")
-set(PROJECT_SO_VERSION 30)
+set(PROJECT_VERSION_FULL "1.0.0")
+set(PROJECT_SO_VERSION 31)
# Remove the dash and anything following, to get the #.#.# version for project()
STRING(REGEX REPLACE "\-.*$" "" VERSION_NUM "${PROJECT_VERSION_FULL}")
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 331ae405c..a27f2f7ac 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -105,7 +105,6 @@ if(ENABLE_WAYLAND_CAPTURE AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
pkg_check_modules(PC_GIO_UNIX QUIET IMPORTED_TARGET gio-unix-2.0)
if(PC_PIPEWIRE_FOUND AND PC_LIBSPA_FOUND AND PC_GIO_FOUND AND PC_GIO_UNIX_FOUND)
set(OPENSHOT_WAYLAND_CAPTURE TRUE)
- list(APPEND OPENSHOT_SOURCES WaylandScreenCaptureReader.cpp)
endif()
endif()
endif()
@@ -212,20 +211,33 @@ target_include_directories(openshot
$)
if(OPENSHOT_WAYLAND_CAPTURE)
- target_compile_definitions(openshot PRIVATE HAVE_WAYLAND_CAPTURE=1)
- target_link_libraries(openshot PRIVATE
+ target_compile_definitions(openshot PRIVATE HAVE_WAYLAND_CAPTURE_PLUGIN=1)
+ target_link_libraries(openshot PRIVATE ${CMAKE_DL_LIBS})
+
+ # PipeWire is optional at runtime. Keeping it in a module prevents the
+ # dynamic loader from rejecting libopenshot on X11 or older Linux systems
+ # that do not provide the PipeWire 0.3 client ABI.
+ add_library(openshot-wayland-capture MODULE WaylandScreenCaptureReader.cpp)
+ target_include_directories(openshot-wayland-capture PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_CURRENT_BINARY_DIR})
+ target_link_libraries(openshot-wayland-capture PRIVATE
+ openshot
PkgConfig::PC_PIPEWIRE
PkgConfig::PC_LIBSPA
PkgConfig::PC_GIO
PkgConfig::PC_GIO_UNIX)
+ set_target_properties(openshot-wayland-capture PROPERTIES
+ BUILD_RPATH "$ORIGIN"
+ INSTALL_RPATH "$ORIGIN")
endif()
-add_feature_info("Wayland screen capture" OPENSHOT_WAYLAND_CAPTURE "Use xdg-desktop-portal ScreenCast and PipeWire")
+add_feature_info("Wayland screen capture" OPENSHOT_WAYLAND_CAPTURE "Use the optional xdg-desktop-portal and PipeWire capture module")
################# LIBOPENSHOT-AUDIO ###################
# Find JUCE-based openshot Audio libraries
if(NOT TARGET OpenShot::Audio)
# Only load if necessary (not for integrated builds)
- find_package(OpenShotAudio 0.6.0 REQUIRED)
+ find_package(OpenShotAudio 1.0.0 REQUIRED)
endif()
target_link_libraries(openshot PUBLIC OpenShot::Audio)
@@ -633,6 +645,12 @@ install(TARGETS openshot
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/libopenshot)
+if(OPENSHOT_WAYLAND_CAPTURE)
+ install(TARGETS openshot-wayland-capture
+ COMPONENT runtime
+ LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR})
+endif()
+
install(DIRECTORY .
COMPONENT devel
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/libopenshot
diff --git a/src/ScreenCaptureReader.cpp b/src/ScreenCaptureReader.cpp
index 10359421b..e52a92569 100644
--- a/src/ScreenCaptureReader.cpp
+++ b/src/ScreenCaptureReader.cpp
@@ -24,6 +24,10 @@
#include
#include
+#if defined(__linux__)
+ #include
+#endif
+
extern "C" {
#include
#include
@@ -43,6 +47,40 @@ extern "C" {
using namespace openshot;
+#if defined(HAVE_WAYLAND_CAPTURE_PLUGIN) && defined(__linux__)
+namespace
+{
+ using WaylandBackendFactory = std::unique_ptr (*) (
+ const ScreenCaptureSettings&, ReaderInfo&);
+
+ void* load_wayland_capture_backend(std::string& error_message)
+ {
+ Dl_info library_info {};
+ if (dladdr(reinterpret_cast(load_wayland_capture_backend), &library_info)
+ && library_info.dli_fname) {
+ std::string library_path(library_info.dli_fname);
+ const auto separator = library_path.find_last_of('/');
+ if (separator != std::string::npos) {
+ const std::string module_path = library_path.substr(0, separator + 1)
+ + "libopenshot-wayland-capture.so";
+ if (void* module = dlopen(module_path.c_str(), RTLD_NOW | RTLD_LOCAL)) {
+ return module;
+ }
+ const char* error = dlerror();
+ error_message = error ? error : "unable to load PipeWire support.";
+ return nullptr;
+ }
+ }
+ void* module = dlopen("libopenshot-wayland-capture.so", RTLD_NOW | RTLD_LOCAL);
+ if (!module) {
+ const char* error = dlerror();
+ error_message = error ? error : "unable to load PipeWire support.";
+ }
+ return module;
+ }
+}
+#endif
+
#if defined(__linux__)
class ScreenCaptureReader::SystemAudioCapture
{
@@ -560,15 +598,10 @@ namespace
}
}
-#if defined(HAVE_WAYLAND_CAPTURE)
-std::unique_ptr CreateWaylandScreenCaptureReader(
- const ScreenCaptureSettings& settings,
- ReaderInfo& info);
-#endif
-
ScreenCaptureReader::ScreenCaptureReader(const ScreenCaptureSettings& new_settings)
: settings(new_settings)
, backend_reader(nullptr)
+ , backend_module(nullptr)
, is_open(false)
, video_stream(-1)
, frames_read(0)
@@ -593,10 +626,33 @@ ScreenCaptureReader::ScreenCaptureReader(const ScreenCaptureSettings& new_settin
}
#endif
if (UsesWaylandPortal()) {
- #if defined(HAVE_WAYLAND_CAPTURE)
- backend_reader = CreateWaylandScreenCaptureReader(settings, info);
+ #if defined(HAVE_WAYLAND_CAPTURE_PLUGIN) && defined(__linux__)
+ std::string module_error;
+ backend_module = load_wayland_capture_backend(module_error);
+ if (!backend_module) {
+ throw InvalidOptions("Wayland screen capture backend is unavailable: "
+ + module_error);
+ }
+ auto factory = reinterpret_cast(
+ dlsym(backend_module, "OpenShotCreateWaylandScreenCaptureReader"));
+ if (!factory) {
+ const char* error = dlerror();
+ dlclose(backend_module);
+ backend_module = nullptr;
+ throw InvalidOptions("Wayland screen capture backend is invalid: "
+ + std::string(error ? error : "factory function is missing."));
+ }
+ try {
+ backend_reader = factory(settings, info);
+ } catch (...) {
+ dlclose(backend_module);
+ backend_module = nullptr;
+ throw;
+ }
if (!backend_reader) {
- throw InvalidOptions("Wayland screen capture backend is unavailable in this build.");
+ dlclose(backend_module);
+ backend_module = nullptr;
+ throw InvalidOptions("Wayland screen capture backend is unavailable.");
}
#else
throw InvalidOptions("Wayland screen capture backend is unavailable in this build.");
@@ -607,10 +663,18 @@ ScreenCaptureReader::ScreenCaptureReader(const ScreenCaptureSettings& new_settin
ScreenCaptureReader::~ScreenCaptureReader()
{
Close();
+#if defined(__linux__)
+ backend_reader.reset();
+ if (backend_module) {
+ dlclose(backend_module);
+ backend_module = nullptr;
+ }
+#endif
}
bool ScreenCaptureReader::IsOpen()
{
+ const std::lock_guard lock(getFrameMutex);
return backend_reader ? backend_reader->IsOpen() : is_open;
}
@@ -620,7 +684,7 @@ bool ScreenCaptureReader::IsBackendSupported(ScreenCaptureBackend backend)
if (backend == SCREEN_CAPTURE_X11 || backend == SCREEN_CAPTURE_AUTO) {
return true;
}
-#if defined(HAVE_WAYLAND_CAPTURE)
+#if defined(HAVE_WAYLAND_CAPTURE_PLUGIN)
if (backend == SCREEN_CAPTURE_WAYLAND) {
return true;
}
@@ -947,11 +1011,11 @@ void ScreenCaptureReader::OpenDecoder()
std::shared_ptr ScreenCaptureReader::GetFrame(int64_t number)
{
+ const std::lock_guard lock(getFrameMutex);
if (backend_reader) {
if (!backend_reader->IsOpen()) {
throw ReaderClosed("The ScreenCaptureReader is closed. Call Open() before GetFrame().");
}
- const std::lock_guard lock(getFrameMutex);
auto frame = backend_reader->GetFrame(number);
if (system_audio && !manual_system_audio) system_audio->AddFrameAudio(frame, number, info.fps);
return frame;
@@ -960,7 +1024,6 @@ std::shared_ptr ScreenCaptureReader::GetFrame(int64_t number)
throw ReaderClosed("The ScreenCaptureReader is closed. Call Open() before GetFrame().");
}
- const std::lock_guard lock(getFrameMutex);
auto frame = DecodeNextFrame(number);
if (system_audio && !manual_system_audio) system_audio->AddFrameAudio(frame, number, info.fps);
return frame;
@@ -1101,13 +1164,20 @@ std::shared_ptr ScreenCaptureReader::DecodeNextFrame(int64_t number)
void ScreenCaptureReader::Close()
{
+ // Signal blocking native reads before waiting for GetFrame(). The FFmpeg
+ // interrupt callback observes close_requested, while backend readers use
+ // Close() to wake their own blocking waits.
close_requested = true;
- if (system_audio) {
- system_audio->Close();
- }
if (backend_reader) {
backend_reader->Close();
}
+
+ // GetFrame() owns all decoder and system-audio use under this mutex. Do not
+ // release those resources until an interrupted read has completely exited.
+ const std::lock_guard lock(getFrameMutex);
+ if (system_audio) {
+ system_audio->Close();
+ }
if (packet) {
av_packet_free(&packet);
}
diff --git a/src/ScreenCaptureReader.h b/src/ScreenCaptureReader.h
index 5b37c6758..afdff777a 100644
--- a/src/ScreenCaptureReader.h
+++ b/src/ScreenCaptureReader.h
@@ -111,6 +111,9 @@ namespace openshot
ScreenCaptureSettings settings;
#ifndef SWIG
std::unique_ptr backend_reader;
+ // The Wayland backend is a runtime-loaded module. Keep it loaded until its
+ // reader is destroyed, since the reader's vtable lives in that module.
+ void* backend_module = nullptr;
#endif
bool is_open;
int video_stream;
diff --git a/src/Timeline.cpp b/src/Timeline.cpp
index 663a83b1e..3d392150c 100644
--- a/src/Timeline.cpp
+++ b/src/Timeline.cpp
@@ -763,6 +763,28 @@ void Timeline::update_open_clips(Clip *clip, bool does_clip_intersect)
// is clip already in list?
bool clip_found = open_clips.count(clip);
+ // The registry, Clip, and nested Reader can become inconsistent after a
+ // transient reader close. Trust the actual objects over open_clips and run
+ // them through a clean Close/Open cycle while this clip still intersects.
+ // Otherwise FrameMapper converts ReaderClosed into a black frame which can
+ // be repeatedly cached on every still-intersecting timing update.
+ if (clip_found && does_clip_intersect)
+ {
+ bool clip_reader_open = false;
+ try {
+ clip_reader_open = clip->Reader() && clip->Reader()->IsOpen();
+ } catch (const ReaderClosed & e) {
+ // A missing/replaced reader is equivalent to a closed reader here.
+ clip_reader_open = false;
+ }
+ if (!clip->IsOpen() || !clip_reader_open)
+ {
+ open_clips.erase(clip);
+ clip->Close();
+ clip_found = false;
+ }
+ }
+
if (clip_found && !does_clip_intersect)
{
// Remove clip from 'opened' list, because it's closed now
diff --git a/src/WaylandBufferUtilities.h b/src/WaylandBufferUtilities.h
new file mode 100644
index 000000000..caf989577
--- /dev/null
+++ b/src/WaylandBufferUtilities.h
@@ -0,0 +1,133 @@
+/**
+ * @file
+ * @brief Bounds-safe helpers for mapped PipeWire video buffers
+ */
+
+// Copyright (c) 2008-2026 OpenShot Studios, LLC
+//
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+#ifndef OPENSHOT_WAYLAND_BUFFER_UTILITIES_H
+#define OPENSHOT_WAYLAND_BUFFER_UTILITIES_H
+
+#include
+#include
+#include
+#include
+#include
+
+namespace openshot::wayland
+{
+ struct PackedVideoLayout
+ {
+ size_t offset = 0;
+ size_t valid_size = 0;
+ int stride = 0;
+ int crop_x = 0;
+ int crop_y = 0;
+ int width = 0;
+ int height = 0;
+ bool valid = false;
+ };
+
+ inline PackedVideoLayout ResolvePackedVideoLayout(
+ size_t max_size,
+ size_t chunk_offset,
+ size_t chunk_size,
+ int chunk_stride,
+ int stream_width,
+ int stream_height,
+ int crop_x,
+ int crop_y,
+ int crop_width,
+ int crop_height)
+ {
+ PackedVideoLayout layout;
+ if (max_size == 0 || stream_width <= 0 || stream_height <= 0
+ || stream_width > std::numeric_limits::max() / 4
+ || chunk_stride < 0) {
+ return layout;
+ }
+
+ layout.offset = chunk_offset % max_size;
+ layout.valid_size = std::min(chunk_size, max_size);
+ layout.stride = chunk_stride > 0 ? chunk_stride : stream_width * 4;
+ if (layout.stride < 4 || layout.valid_size < static_cast(layout.stride)) {
+ return layout;
+ }
+
+ const int readable_width = std::min(
+ stream_width,
+ layout.stride / 4);
+ const int readable_height = std::min(
+ stream_height,
+ static_cast(layout.valid_size / static_cast(layout.stride)));
+
+ layout.crop_x = std::max(0, crop_x);
+ layout.crop_y = std::max(0, crop_y);
+ if (layout.crop_x >= readable_width || layout.crop_y >= readable_height) {
+ return layout;
+ }
+
+ const int requested_width = crop_width > 0
+ ? crop_width
+ : stream_width - layout.crop_x;
+ const int requested_height = crop_height > 0
+ ? crop_height
+ : stream_height - layout.crop_y;
+ layout.width = std::min(requested_width, readable_width - layout.crop_x);
+ layout.height = std::min(requested_height, readable_height - layout.crop_y);
+
+ // H.264 requires even dimensions, and capture callers already expect them.
+ layout.width -= layout.width % 2;
+ layout.height -= layout.height % 2;
+ if (layout.width <= 0 || layout.height <= 0) {
+ return layout;
+ }
+
+ const size_t final_row_end =
+ static_cast(layout.crop_y + layout.height - 1) * layout.stride
+ + static_cast(layout.crop_x + layout.width) * 4;
+ if (final_row_end > layout.valid_size) {
+ return layout;
+ }
+
+ layout.valid = true;
+ return layout;
+ }
+
+ inline int DamageFrameWaitMilliseconds(int fps_num, int fps_den, bool have_last_frame)
+ {
+ if (!have_last_frame) {
+ return 5000;
+ }
+ const double fps = fps_num > 0 && fps_den > 0
+ ? static_cast(fps_num) / fps_den
+ : 30.0;
+ return std::max(1, static_cast(1000.0 / std::max(1.0, fps)));
+ }
+
+ inline bool CopyWrappedBytes(
+ const uint8_t* source,
+ size_t max_size,
+ size_t chunk_offset,
+ size_t logical_offset,
+ uint8_t* destination,
+ size_t byte_count)
+ {
+ if (!source || !destination || max_size == 0 || byte_count > max_size) {
+ return false;
+ }
+ const size_t physical_offset = (
+ (chunk_offset % max_size) + (logical_offset % max_size)
+ ) % max_size;
+ const size_t first_size = std::min(byte_count, max_size - physical_offset);
+ std::memcpy(destination, source + physical_offset, first_size);
+ if (first_size < byte_count) {
+ std::memcpy(destination + first_size, source, byte_count - first_size);
+ }
+ return true;
+ }
+}
+
+#endif
diff --git a/src/WaylandScreenCaptureReader.cpp b/src/WaylandScreenCaptureReader.cpp
index 8147877f3..71f28db57 100644
--- a/src/WaylandScreenCaptureReader.cpp
+++ b/src/WaylandScreenCaptureReader.cpp
@@ -35,6 +35,7 @@
#include "Exceptions.h"
#include "Frame.h"
+#include "WaylandBufferUtilities.h"
#include "ZmqLogger.h"
using namespace openshot;
@@ -397,7 +398,12 @@ class WaylandScreenCaptureReader final : public ScreenCaptureReader::CaptureBack
CapturedFrame captured;
{
std::unique_lock lock(queue_mutex);
- const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
+ const auto wait_duration = std::chrono::milliseconds(
+ wayland::DamageFrameWaitMilliseconds(
+ settings.fps.num,
+ settings.fps.den,
+ have_last_frame));
+ const auto deadline = std::chrono::steady_clock::now() + wait_duration;
while (frame_queue.empty() && !stream_error && open && std::chrono::steady_clock::now() < deadline) {
lock.unlock();
while (g_main_context_iteration(nullptr, FALSE)) {
@@ -406,16 +412,28 @@ class WaylandScreenCaptureReader final : public ScreenCaptureReader::CaptureBack
queue_condition.wait_for(lock, std::chrono::milliseconds(100));
}
if (frame_queue.empty() && !stream_error && open) {
- throw InvalidFile("Timed out waiting for a Wayland capture frame.", "wayland");
+ if (have_last_frame) {
+ captured = last_frame;
+ } else {
+ throw InvalidFile("Timed out waiting for the first Wayland capture frame.", "wayland");
+ }
}
if (stream_error) {
throw InvalidFile("Wayland capture stream failed.", "wayland");
}
if (frame_queue.empty()) {
- throw ReaderClosed("The Wayland screen capture stream is closed.");
+ if (!open) {
+ throw ReaderClosed("The Wayland screen capture stream is closed.");
+ }
+ if (!have_last_frame) {
+ throw InvalidFile("Timed out waiting for the first Wayland capture frame.", "wayland");
+ }
+ } else {
+ captured = std::move(frame_queue.front());
+ frame_queue.pop_front();
+ last_frame = captured;
+ have_last_frame = true;
}
- captured = std::move(frame_queue.front());
- frame_queue.pop_front();
}
const int bytes_per_pixel = 4;
@@ -680,10 +698,27 @@ class WaylandScreenCaptureReader final : public ScreenCaptureReader::CaptureBack
self->info.display_ratio.Reduce();
}
- uint8_t params_buffer[256];
+ uint8_t params_buffer[1024];
spa_pod_builder builder = SPA_POD_BUILDER_INIT(params_buffer, sizeof(params_buffer));
- const spa_pod* params[2];
+ const int stride = self->stream_width * 4;
+ const int buffer_size = stride * self->stream_height;
+ const spa_pod* params[3];
params[0] = static_cast(spa_pod_builder_add_object(
+ &builder,
+ SPA_TYPE_OBJECT_ParamBuffers,
+ SPA_PARAM_Buffers,
+ SPA_PARAM_BUFFERS_buffers,
+ SPA_POD_CHOICE_RANGE_Int(8, 2, 32),
+ SPA_PARAM_BUFFERS_blocks,
+ SPA_POD_Int(1),
+ SPA_PARAM_BUFFERS_size,
+ SPA_POD_Int(buffer_size),
+ SPA_PARAM_BUFFERS_stride,
+ SPA_POD_Int(stride),
+ SPA_PARAM_BUFFERS_dataType,
+ SPA_POD_CHOICE_FLAGS_Int(
+ (1 << SPA_DATA_MemPtr) | (1 << SPA_DATA_MemFd))));
+ params[1] = static_cast(spa_pod_builder_add_object(
&builder,
SPA_TYPE_OBJECT_ParamMeta,
SPA_PARAM_Meta,
@@ -691,7 +726,7 @@ class WaylandScreenCaptureReader final : public ScreenCaptureReader::CaptureBack
SPA_POD_Id(SPA_META_Header),
SPA_PARAM_META_size,
SPA_POD_Int(sizeof(spa_meta_header))));
- params[1] = static_cast(spa_pod_builder_add_object(
+ params[2] = static_cast(spa_pod_builder_add_object(
&builder,
SPA_TYPE_OBJECT_ParamMeta,
SPA_PARAM_Meta,
@@ -699,13 +734,25 @@ class WaylandScreenCaptureReader final : public ScreenCaptureReader::CaptureBack
SPA_POD_Id(SPA_META_VideoCrop),
SPA_PARAM_META_size,
SPA_POD_Int(sizeof(spa_meta_region))));
- pw_stream_update_params(self->stream, params, 2);
+ pw_stream_update_params(self->stream, params, 3);
}
static void OnStreamProcess(void* data)
{
auto* self = static_cast(data);
- pw_buffer* buffer = pw_stream_dequeue_buffer(self->stream);
+ // Drain the stream and process only the newest buffer. Returning stale
+ // buffers immediately prevents a damage-driven window stream from
+ // accumulating latency after it becomes visible again.
+ pw_buffer* buffer = nullptr;
+ pw_buffer* next_buffer = pw_stream_dequeue_buffer(self->stream);
+ while (next_buffer) {
+ if (buffer) {
+ pw_stream_queue_buffer(self->stream, buffer);
+ self->dropped_packets++;
+ }
+ buffer = next_buffer;
+ next_buffer = pw_stream_dequeue_buffer(self->stream);
+ }
if (!buffer) {
return;
}
@@ -731,9 +778,6 @@ class WaylandScreenCaptureReader final : public ScreenCaptureReader::CaptureBack
spa_data& data = spa_buffer->datas[0];
const spa_chunk* chunk = data.chunk;
- const uint8_t* src = static_cast(data.data) + (chunk ? chunk->offset : 0);
- const int stride = chunk && chunk->stride > 0 ? chunk->stride : stream_width * 4;
- const int readable_rows = chunk && chunk->size > 0 ? static_cast(chunk->size) / stride : stream_height;
int crop_x = 0;
int crop_y = 0;
int crop_width = stream_width;
@@ -754,35 +798,45 @@ class WaylandScreenCaptureReader final : public ScreenCaptureReader::CaptureBack
crop_logged = true;
}
}
- if (crop_width <= 0 || crop_height <= 0) {
- dropped_packets++;
- return;
- }
- if (crop_width % 2 != 0) {
- crop_width--;
- }
- if (crop_height % 2 != 0) {
- crop_height--;
- }
- if (crop_width <= 0 || crop_height <= 0) {
- dropped_packets++;
- return;
- }
- const int rows = std::min(crop_height, readable_rows - crop_y);
- if (rows <= 0) {
+
+ const auto layout = wayland::ResolvePackedVideoLayout(
+ static_cast(data.maxsize),
+ chunk ? static_cast(chunk->offset) : 0,
+ chunk ? static_cast(chunk->size) : static_cast(data.maxsize),
+ chunk ? chunk->stride : 0,
+ stream_width,
+ stream_height,
+ crop_x,
+ crop_y,
+ crop_width,
+ crop_height);
+ if (!layout.valid) {
dropped_packets++;
return;
}
CapturedFrame frame;
- frame.width = crop_width;
- frame.height = crop_height;
+ frame.width = layout.width;
+ frame.height = layout.height;
frame.rgba.assign(static_cast(frame.width) * frame.height * 4, 0);
- for (int y = 0; y < rows; ++y) {
- const uint8_t* row = src + static_cast(y + crop_y) * stride;
- for (int x = 0; x < crop_width; ++x) {
- const uint8_t* pixel = row + static_cast(x + crop_x) * 4;
+ std::vector source_row(static_cast(frame.width) * 4);
+ for (int y = 0; y < frame.height; ++y) {
+ const size_t logical_offset =
+ static_cast(y + layout.crop_y) * layout.stride
+ + static_cast(layout.crop_x) * 4;
+ if (!wayland::CopyWrappedBytes(
+ static_cast(data.data),
+ static_cast(data.maxsize),
+ layout.offset,
+ logical_offset,
+ source_row.data(),
+ source_row.size())) {
+ dropped_packets++;
+ return;
+ }
+ for (int x = 0; x < frame.width; ++x) {
+ const uint8_t* pixel = source_row.data() + static_cast(x) * 4;
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
@@ -818,10 +872,10 @@ class WaylandScreenCaptureReader final : public ScreenCaptureReader::CaptureBack
}
frame_queue.push_back(std::move(frame));
}
- if (info.width != crop_width || info.height != crop_height) {
- info.width = crop_width;
- info.height = crop_height;
- info.display_ratio = Fraction(crop_width, crop_height);
+ if (info.width != frame.width || info.height != frame.height) {
+ info.width = frame.width;
+ info.height = frame.height;
+ info.display_ratio = Fraction(frame.width, frame.height);
info.display_ratio.Reduce();
}
queue_condition.notify_one();
@@ -901,6 +955,8 @@ class WaylandScreenCaptureReader final : public ScreenCaptureReader::CaptureBack
std::mutex queue_mutex;
std::condition_variable queue_condition;
std::deque frame_queue;
+ CapturedFrame last_frame;
+ bool have_last_frame = false;
static const pw_stream_events stream_events;
};
@@ -920,7 +976,7 @@ const pw_stream_events WaylandScreenCaptureReader::stream_events = {
nullptr
};
-std::unique_ptr CreateWaylandScreenCaptureReader(
+extern "C" std::unique_ptr OpenShotCreateWaylandScreenCaptureReader(
const ScreenCaptureSettings& settings,
ReaderInfo& info)
{
diff --git a/tests/ScreenCaptureReader.cpp b/tests/ScreenCaptureReader.cpp
index bb5a024b5..b0a50316e 100644
--- a/tests/ScreenCaptureReader.cpp
+++ b/tests/ScreenCaptureReader.cpp
@@ -14,12 +14,95 @@
#include "Exceptions.h"
#include "ScreenCaptureReader.h"
+#include "WaylandBufferUtilities.h"
#include
+#include
#include
+#include
using namespace openshot;
+TEST_CASE("Wayland packed video layout clamps unsafe PipeWire metadata",
+ "[libopenshot][screencapturereader][wayland]")
+{
+ SECTION("chunk offsets follow PipeWire modulo semantics")
+ {
+ const auto layout = wayland::ResolvePackedVideoLayout(
+ 32, 37, 32, 16, 4, 2, 0, 0, 4, 2);
+ REQUIRE(layout.valid);
+ CHECK(layout.offset == 5);
+ CHECK(layout.valid_size == 32);
+ CHECK(layout.width == 4);
+ CHECK(layout.height == 2);
+ }
+
+ SECTION("chunk size limits readable rows")
+ {
+ const auto layout = wayland::ResolvePackedVideoLayout(
+ 64, 0, 32, 16, 4, 4, 0, 0, 4, 4);
+ REQUIRE(layout.valid);
+ CHECK(layout.width == 4);
+ CHECK(layout.height == 2);
+ }
+
+ SECTION("empty chunks are rejected instead of reading stale allocation data")
+ {
+ const auto layout = wayland::ResolvePackedVideoLayout(
+ 64, 0, 0, 16, 4, 4, 0, 0, 4, 4);
+ CHECK_FALSE(layout.valid);
+ }
+
+ SECTION("crop width cannot exceed row stride")
+ {
+ const auto layout = wayland::ResolvePackedVideoLayout(
+ 64, 0, 64, 16, 8, 4, 2, 0, 6, 4);
+ REQUIRE(layout.valid);
+ CHECK(layout.crop_x == 2);
+ CHECK(layout.width == 2);
+ CHECK(layout.height == 4);
+ }
+
+ SECTION("crop outside readable memory is rejected")
+ {
+ const auto layout = wayland::ResolvePackedVideoLayout(
+ 64, 0, 64, 16, 8, 4, 4, 0, 4, 4);
+ CHECK_FALSE(layout.valid);
+ }
+
+ SECTION("negative producer stride is rejected safely")
+ {
+ const auto layout = wayland::ResolvePackedVideoLayout(
+ 64, 0, 64, -16, 4, 4, 0, 0, 4, 4);
+ CHECK_FALSE(layout.valid);
+ }
+}
+
+TEST_CASE("Wayland packed video rows copy safely across ring-buffer wrap",
+ "[libopenshot][screencapturereader][wayland]")
+{
+ const std::vector source {0, 1, 2, 3, 4, 5, 6, 7};
+ std::vector destination(6, 0);
+
+ REQUIRE(wayland::CopyWrappedBytes(
+ source.data(), source.size(), 6, 0,
+ destination.data(), destination.size()));
+ CHECK(destination == std::vector {6, 7, 0, 1, 2, 3});
+
+ CHECK_FALSE(wayland::CopyWrappedBytes(
+ source.data(), source.size(), 0, 0,
+ destination.data(), source.size() + 1));
+}
+
+TEST_CASE("Wayland damage-driven streams repeat at the requested cadence",
+ "[libopenshot][screencapturereader][wayland]")
+{
+ CHECK(wayland::DamageFrameWaitMilliseconds(30, 1, false) == 5000);
+ CHECK(wayland::DamageFrameWaitMilliseconds(30, 1, true) == 33);
+ CHECK(wayland::DamageFrameWaitMilliseconds(60, 1, true) == 16);
+ CHECK(wayland::DamageFrameWaitMilliseconds(0, 0, true) == 33);
+}
+
TEST_CASE("Screen capture settings validation", "[libopenshot][screencapturereader]")
{
ScreenCaptureSettings settings;
@@ -104,6 +187,33 @@ TEST_CASE("Screen capture reader reports configured video info", "[libopenshot][
#endif
}
+TEST_CASE("Closed screen capture reader consistently rejects frames", "[libopenshot][screencapturereader][lifecycle]")
+{
+ ScreenCaptureSettings settings;
+#if defined(__linux__)
+ settings.backend = SCREEN_CAPTURE_X11;
+ settings.display = ":99.0";
+#elif defined(_WIN32)
+ settings.backend = SCREEN_CAPTURE_WINDOWS_GDI;
+ settings.display = "desktop";
+#elif defined(__APPLE__)
+ settings.backend = SCREEN_CAPTURE_MAC_AVFOUNDATION;
+ settings.display = "Capture screen 0:none";
+#else
+ return;
+#endif
+ settings.width = 640;
+ settings.height = 360;
+ settings.fps = Fraction(30, 1);
+
+ ScreenCaptureReader reader(settings);
+ CHECK_FALSE(reader.IsOpen());
+ CHECK_NOTHROW(reader.Close());
+ CHECK_NOTHROW(reader.Close());
+ CHECK_FALSE(reader.IsOpen());
+ CHECK_THROWS_AS(reader.GetFrame(1), ReaderClosed);
+}
+
TEST_CASE("Screen capture system audio settings follow backend capability", "[libopenshot][screencapturereader][audio]")
{
ScreenCaptureSettings settings;
diff --git a/tests/Timeline.cpp b/tests/Timeline.cpp
index 460aca3e2..553095d39 100644
--- a/tests/Timeline.cpp
+++ b/tests/Timeline.cpp
@@ -1761,6 +1761,99 @@ TEST_CASE( "Timeline retries opening intersecting clip after transient open fail
t.RemoveClip(&clip);
}
+TEST_CASE( "Timeline repairs stale open clip state while clip still intersects playhead", "[libopenshot][timeline][cache]" )
+{
+ Timeline t(640, 480, Fraction(30, 1), 44100, 2, LAYOUT_STEREO);
+ t.Open();
+
+ // Model a long clip whose visible extent is much wider than the editor
+ // viewport, with the playhead well inside the clip rather than at an edge.
+ TimelineFailFirstOpenReader red_reader(
+ /*width=*/640, /*height=*/480, /*fps_num=*/30, /*fps_den=*/1,
+ /*length_frames=*/18000, QColor(220, 20, 30, 255)
+ );
+ Clip clip(&red_reader);
+ clip.Id("STALE_OPEN_CLIP_STATE");
+ clip.Layer(5);
+ clip.Position(0.0);
+ clip.Start(0.0);
+ clip.End(600.0);
+ t.AddClip(&clip);
+
+ const int64_t playhead_frame = 4501; // 150 seconds, deep inside the clip
+ std::shared_ptr initial = t.GetFrame(playhead_frame);
+ REQUIRE(initial != nullptr);
+ CHECK(initial->GetImage()->pixelColor(320, 240).red() == Approx(220).margin(2));
+
+ // Reproduce the suspected invariant violation: Timeline::open_clips still
+ // contains this Clip pointer and Clip::IsOpen() is still true, but the
+ // FrameMapper's nested source Reader has become closed.
+ // A timing nudge which continues to intersect the playhead should not leave
+ // the preview permanently black.
+ REQUIRE(clip.IsOpen());
+ auto* mapper = dynamic_cast(clip.Reader());
+ REQUIRE(mapper != nullptr);
+ mapper->Reader()->Close();
+ CHECK(clip.IsOpen());
+ CHECK_FALSE(mapper->IsOpen());
+ t.GetCache()->Remove(playhead_frame);
+
+ std::stringstream nudge_right;
+ nudge_right << "[{\"type\":\"update\",\"key\":[\"clips\",{\"id\":\""
+ << clip.Id()
+ << "\"}],\"value\":{\"id\":\"" << clip.Id()
+ << "\",\"position\":0.1,\"start\":0.0,\"end\":600.0},\"partial\":true}]";
+ t.ApplyJsonDiff(nudge_right.str());
+
+ std::shared_ptr after_nudge = t.GetFrame(playhead_frame);
+ REQUIRE(after_nudge != nullptr);
+ const QColor after_nudge_pixel = after_nudge->GetImage()->pixelColor(320, 240);
+ CHECK(after_nudge_pixel.red() == Approx(220).margin(2));
+ CHECK(after_nudge_pixel.green() == Approx(20).margin(2));
+ CHECK(after_nudge_pixel.blue() == Approx(30).margin(2));
+
+ // A second nudge which still covers the playhead must remain healthy after
+ // the stale open state was repaired by the first frame request.
+ std::stringstream nudge_again;
+ nudge_again << "[{\"type\":\"update\",\"key\":[\"clips\",{\"id\":\""
+ << clip.Id()
+ << "\"}],\"value\":{\"id\":\"" << clip.Id()
+ << "\",\"position\":0.2,\"start\":0.0,\"end\":600.0},\"partial\":true}]";
+ t.ApplyJsonDiff(nudge_again.str());
+ std::shared_ptr after_second_nudge = t.GetFrame(playhead_frame);
+ REQUIRE(after_second_nudge != nullptr);
+ CHECK(after_second_nudge->GetImage()->pixelColor(320, 240).red() == Approx(220).margin(2));
+
+ // Match the UI recovery gesture: move the clip completely past the playhead
+ // and render once so update_open_clips() removes its stale registry entry.
+ std::stringstream move_past_playhead;
+ move_past_playhead << "[{\"type\":\"update\",\"key\":[\"clips\",{\"id\":\""
+ << clip.Id()
+ << "\"}],\"value\":{\"id\":\"" << clip.Id()
+ << "\",\"position\":200.0,\"start\":0.0,\"end\":600.0},\"partial\":true}]";
+ t.ApplyJsonDiff(move_past_playhead.str());
+ std::shared_ptr outside_clip = t.GetFrame(playhead_frame);
+ REQUIRE(outside_clip != nullptr);
+ CHECK(outside_clip->GetImage()->pixelColor(320, 240) == QColor(0, 0, 0, 255));
+
+ // Moving it back over the same playhead now forces a fresh Clip::Open(), and
+ // the source image returns.
+ std::stringstream move_back;
+ move_back << "[{\"type\":\"update\",\"key\":[\"clips\",{\"id\":\""
+ << clip.Id()
+ << "\"}],\"value\":{\"id\":\"" << clip.Id()
+ << "\",\"position\":0.2,\"start\":0.0,\"end\":600.0},\"partial\":true}]";
+ t.ApplyJsonDiff(move_back.str());
+ std::shared_ptr after_move_back = t.GetFrame(playhead_frame);
+ REQUIRE(after_move_back != nullptr);
+ const QColor recovered_pixel = after_move_back->GetImage()->pixelColor(320, 240);
+ CHECK(recovered_pixel.red() == Approx(220).margin(2));
+ CHECK(recovered_pixel.green() == Approx(20).margin(2));
+ CHECK(recovered_pixel.blue() == Approx(30).margin(2));
+
+ t.RemoveClip(&clip);
+}
+
TEST_CASE( "ApplyJSONDiff alpha updates refresh fixed-frame preview content", "[libopenshot][timeline]" )
{
// Deterministic solid-color readers avoid any fixture/image ambiguity.