From ffcff368aea36df251b4e2478c7b61e1b7556976 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Sat, 25 Jul 2026 16:22:37 -0500 Subject: [PATCH 1/5] Bumping version to 1.0.0, SO 31, requiring OpenShotAudio 1.0.0 --- CMakeLists.txt | 4 ++-- src/CMakeLists.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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..338d98946 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -225,7 +225,7 @@ add_feature_info("Wayland screen capture" OPENSHOT_WAYLAND_CAPTURE "Use xdg-desk # 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) From d4d87d8dcfffe52e853c4222e5e999511b32b695 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Sat, 25 Jul 2026 18:09:54 -0500 Subject: [PATCH 2/5] Synchronize screen capture shutdown --- src/ScreenCaptureReader.cpp | 17 ++++++++++++----- tests/ScreenCaptureReader.cpp | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/ScreenCaptureReader.cpp b/src/ScreenCaptureReader.cpp index 10359421b..8a545a517 100644 --- a/src/ScreenCaptureReader.cpp +++ b/src/ScreenCaptureReader.cpp @@ -611,6 +611,7 @@ ScreenCaptureReader::~ScreenCaptureReader() bool ScreenCaptureReader::IsOpen() { + const std::lock_guard lock(getFrameMutex); return backend_reader ? backend_reader->IsOpen() : is_open; } @@ -947,11 +948,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 +961,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 +1101,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/tests/ScreenCaptureReader.cpp b/tests/ScreenCaptureReader.cpp index bb5a024b5..01b1c7e58 100644 --- a/tests/ScreenCaptureReader.cpp +++ b/tests/ScreenCaptureReader.cpp @@ -104,6 +104,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; From 7bf0aeb4bc5c706291127f787bf7eecf5d7b4b4f Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Sun, 26 Jul 2026 00:10:10 -0500 Subject: [PATCH 3/5] Harden Wayland PipeWire window capture --- src/WaylandBufferUtilities.h | 133 ++++++++++++++++++++++++++++ src/WaylandScreenCaptureReader.cpp | 134 ++++++++++++++++++++--------- tests/ScreenCaptureReader.cpp | 83 ++++++++++++++++++ 3 files changed, 311 insertions(+), 39 deletions(-) create mode 100644 src/WaylandBufferUtilities.h 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..b67de7176 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; }; diff --git a/tests/ScreenCaptureReader.cpp b/tests/ScreenCaptureReader.cpp index 01b1c7e58..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; From 9f642482adf12923e00644be49a05501b2d237f6 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 17 Aug 2026 17:12:51 -0500 Subject: [PATCH 4/5] Load PipeWire capture backend on demand --- src/CMakeLists.txt | 26 ++++++++-- src/ScreenCaptureReader.cpp | 83 ++++++++++++++++++++++++++---- src/ScreenCaptureReader.h | 3 ++ src/WaylandScreenCaptureReader.cpp | 2 +- 4 files changed, 99 insertions(+), 15 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 338d98946..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,14 +211,27 @@ 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 @@ -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 8a545a517..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,6 +663,13 @@ 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() @@ -621,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; } 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/WaylandScreenCaptureReader.cpp b/src/WaylandScreenCaptureReader.cpp index b67de7176..71f28db57 100644 --- a/src/WaylandScreenCaptureReader.cpp +++ b/src/WaylandScreenCaptureReader.cpp @@ -976,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) { From 732f18294e39b1ba9f76ea38c72a84f084d6f611 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 18 Aug 2026 01:00:44 -0500 Subject: [PATCH 5/5] Recover stale timeline clip reader state --- src/Timeline.cpp | 22 +++++++++++ tests/Timeline.cpp | 93 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) 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/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.