From 06ddfc1320201416de2a952beaf67a83d705076e Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Jun 2025 09:24:16 -0500 Subject: [PATCH 1/6] Add virtual Touch() function to CacheBase, and rename MoveToFront in CacheMemory.cpp and CacheDisk.cpp to Touch. --- src/CacheBase.h | 4 ++++ src/CacheDisk.cpp | 4 ++-- src/CacheDisk.h | 2 +- src/CacheMemory.cpp | 4 ++-- src/CacheMemory.h | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/CacheBase.h b/src/CacheBase.h index 2c31c9be..043f4b82 100644 --- a/src/CacheBase.h +++ b/src/CacheBase.h @@ -93,6 +93,10 @@ namespace openshot { /// @param end_frame_number The ending frame number of the cached frame virtual void Remove(int64_t start_frame_number, int64_t end_frame_number) = 0; + /// @brief Move frame to front of queue (so it lasts longer) + /// @param frame_number The frame number of the cached frame + virtual void Touch(int64_t frame_number) = 0; + /// Gets the maximum bytes value int64_t GetMaxBytes() { return max_bytes; }; diff --git a/src/CacheDisk.cpp b/src/CacheDisk.cpp index f969899b..960744fa 100644 --- a/src/CacheDisk.cpp +++ b/src/CacheDisk.cpp @@ -94,7 +94,7 @@ void CacheDisk::Add(std::shared_ptr frame) // Freshen frame if it already exists if (frames.count(frame_number)) // Move frame to front of queue - MoveToFront(frame_number); + Touch(frame_number); else { @@ -334,7 +334,7 @@ void CacheDisk::Remove(int64_t start_frame_number, int64_t end_frame_number) } // Move frame to front of queue (so it lasts longer) -void CacheDisk::MoveToFront(int64_t frame_number) +void CacheDisk::Touch(int64_t frame_number) { // Does frame exists in cache? if (frames.count(frame_number)) diff --git a/src/CacheDisk.h b/src/CacheDisk.h index 50a2dc67..2658ef29 100644 --- a/src/CacheDisk.h +++ b/src/CacheDisk.h @@ -91,7 +91,7 @@ namespace openshot { /// @brief Move frame to front of queue (so it lasts longer) /// @param frame_number The frame number of the cached frame - void MoveToFront(int64_t frame_number); + void Touch(int64_t frame_number); /// @brief Remove a specific frame /// @param frame_number The frame number of the cached frame diff --git a/src/CacheMemory.cpp b/src/CacheMemory.cpp index ce30c077..b768c12f 100644 --- a/src/CacheMemory.cpp +++ b/src/CacheMemory.cpp @@ -52,7 +52,7 @@ void CacheMemory::Add(std::shared_ptr frame) // Freshen frame if it already exists if (frames.count(frame_number)) // Move frame to front of queue - MoveToFront(frame_number); + Touch(frame_number); else { @@ -192,7 +192,7 @@ void CacheMemory::Remove(int64_t start_frame_number, int64_t end_frame_number) } // Move frame to front of queue (so it lasts longer) -void CacheMemory::MoveToFront(int64_t frame_number) +void CacheMemory::Touch(int64_t frame_number) { // Create a scoped lock, to protect the cache from multiple threads const std::lock_guard lock(*cacheMutex); diff --git a/src/CacheMemory.h b/src/CacheMemory.h index 03d3a415..e35fdb11 100644 --- a/src/CacheMemory.h +++ b/src/CacheMemory.h @@ -74,7 +74,7 @@ namespace openshot { /// @brief Move frame to front of queue (so it lasts longer) /// @param frame_number The frame number of the cached frame - void MoveToFront(int64_t frame_number); + void Touch(int64_t frame_number); /// @brief Remove a specific frame /// @param frame_number The frame number of the cached frame From 6d5e55175bcf5dae0650bb4066777eb0ee1c9d43 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Jun 2025 16:23:17 -0500 Subject: [PATCH 2/6] Huge refactor of VideoCacheThread, to provide better caching, fixing many edge cases that cause our cache to become unsynced with playback. This is still a work-in-progress though.. --- src/Qt/VideoCacheThread.cpp | 487 +++++++++++++++++++++--------------- src/Qt/VideoCacheThread.h | 169 ++++++++----- 2 files changed, 401 insertions(+), 255 deletions(-) diff --git a/src/Qt/VideoCacheThread.cpp b/src/Qt/VideoCacheThread.cpp index 35dc9f8c..737f5cbb 100644 --- a/src/Qt/VideoCacheThread.cpp +++ b/src/Qt/VideoCacheThread.cpp @@ -6,102 +6,54 @@ * @ref License */ -// Copyright (c) 2008-2019 OpenShot Studios, LLC +// Copyright (c) 2008-2025 OpenShot Studios, LLC // // SPDX-License-Identifier: LGPL-3.0-or-later #include "VideoCacheThread.h" - #include "CacheBase.h" #include "Exceptions.h" #include "Frame.h" -#include "OpenMPUtilities.h" #include "Settings.h" #include "Timeline.h" - -#include -#include // for std::this_thread::sleep_for -#include // for std::chrono::microseconds +#include +#include namespace openshot { - // Constructor - VideoCacheThread::VideoCacheThread() - : Thread("video-cache"), speed(0), last_speed(1), is_playing(false), - reader(NULL), current_display_frame(1), cached_frame_count(0), - min_frames_ahead(4), max_frames_ahead(8), should_pause_cache(false), - timeline_max_frame(0), should_break(false) + // Constructor + VideoCacheThread::VideoCacheThread() + : Thread("video-cache") + , speed(0) + , last_speed(1) + , last_dir(1) // Assume forward (+1) on first launch + , is_playing(false) + , userSeeked(false) + , requested_display_frame(1) + , current_display_frame(1) + , cached_frame_count(0) + , min_frames_ahead(4) + , max_frames_ahead(8) + , timeline_max_frame(0) + , should_pause_cache(false) + , should_break(false) + , reader(nullptr) + , force_directional_cache(false) { } // Destructor - VideoCacheThread::~VideoCacheThread() + VideoCacheThread::~VideoCacheThread() { } - // Seek the reader to a particular frame number - void VideoCacheThread::Seek(int64_t new_position) - { - requested_display_frame = new_position; - } - - // Seek the reader to a particular frame number and optionally start the pre-roll - void VideoCacheThread::Seek(int64_t new_position, bool start_preroll) + // Seek the reader to a particular frame number + void VideoCacheThread::setSpeed(int new_speed) { - // Get timeline instance - Timeline *t = (Timeline *) reader; - - // Calculate last frame # on timeline (to prevent caching past this point) - timeline_max_frame = t->GetMaxFrame(); - - // Determine previous frame number (depending on last non-zero/non-paused speed) - int64_t previous_frame = new_position; - if (last_speed < 0) { - // backwards - previous_frame++; - } else if (last_speed > 0) { - // forward - previous_frame--; - } - if (previous_frame <= 0) { - // min frame is 1 - previous_frame = 1; - } - - // Clear cache if previous frame outside the cached range, which means we are - // requesting a non-contigous frame compared to our current cache range - if (new_position >= 1 && new_position <= timeline_max_frame && !reader->GetCache()->Contains(previous_frame)) { - // Clear cache - t->ClearAllCache(); - - // Break out of any existing cache loop - should_break = true; - - // Force cache direction back to forward - last_speed = 1; - } - - // Reset pre-roll when requested frame is not currently cached - if (start_preroll && reader && reader->GetCache() && !reader->GetCache()->Contains(new_position)) { - // Break out of any existing cache loop - should_break = true; - - // Reset stats and allow cache to rebuild (if paused) - cached_frame_count = 0; - if (speed == 0) { - should_pause_cache = false; - } - } - - // Actually update seek position - Seek(new_position); - } - - // Set Speed (The speed and direction to playback a reader (1=normal, 2=fast, 3=faster, -1=rewind, etc...) - void VideoCacheThread::setSpeed(int new_speed) { + // Only update last_speed and last_dir when new_speed is non-zero. if (new_speed != 0) { - // Track last non-zero speed last_speed = new_speed; + last_dir = (new_speed > 0 ? 1 : -1); } speed = new_speed; } @@ -109,162 +61,301 @@ namespace openshot // Get the size in bytes of a frame (rough estimate) int64_t VideoCacheThread::getBytes(int width, int height, int sample_rate, int channels, float fps) { - int64_t total_bytes = 0; - total_bytes += static_cast(width * height * sizeof(char) * 4); + // Estimate memory for RGBA video frame + int64_t bytes = static_cast(width) * height * sizeof(char) * 4; - // approximate audio size (sample rate / 24 fps) - total_bytes += ((sample_rate * channels) / fps) * sizeof(float); + // Approximate audio memory: (sample_rate * channels) samples per second, + // divided across fps frames, each sample is sizeof(float). + bytes += ((sample_rate * channels) / fps) * sizeof(float); - // return size of this frame - return total_bytes; + return bytes; } - // Play the video - void VideoCacheThread::Play() { - // Start playing - is_playing = true; - } + // Play the video + void VideoCacheThread::Play() + { + is_playing = true; + } - // Stop the audio - void VideoCacheThread::Stop() { - // Stop playing - is_playing = false; - } + // Stop the audio + void VideoCacheThread::Stop() + { + is_playing = false; + } - // Is cache ready for playback (pre-roll) - bool VideoCacheThread::isReady() { - return (cached_frame_count > min_frames_ahead); - } + // Is cache ready for playback (pre-roll) + bool VideoCacheThread::isReady() + { + // Return true when pre-roll has cached at least min_frames_ahead frames. + return (cached_frame_count > min_frames_ahead); + } // Start the thread void VideoCacheThread::run() { - // Types for storing time durations in whole and fractional microseconds - using micro_sec = std::chrono::microseconds; + using micro_sec = std::chrono::microseconds; using double_micro_sec = std::chrono::duration; - while (!threadShouldExit() && is_playing) { - // Get settings - Settings *s = Settings::Instance(); + int64_t last_cached_index = 0; + int64_t cache_start_index = 0; + bool last_paused = true; - // init local vars - min_frames_ahead = s->VIDEO_CACHE_MIN_PREROLL_FRAMES; - max_frames_ahead = s->VIDEO_CACHE_MAX_PREROLL_FRAMES; + while (!threadShouldExit()) { + Settings* settings = Settings::Instance(); + CacheBase* cache = reader ? reader->GetCache() : nullptr; - // Calculate on-screen time for a single frame - const auto frame_duration = double_micro_sec(1000000.0 / reader->info.fps.ToDouble()); - int current_speed = speed; - - // Increment and direction for cache loop - int64_t increment = 1; - - // Check for empty cache (and re-trigger preroll) - // This can happen when the user manually empties the timeline cache - if (reader->GetCache()->Count() == 0) { - should_pause_cache = false; - cached_frame_count = 0; - } - - // Update current display frame - current_display_frame = requested_display_frame; - - if (current_speed == 0 && should_pause_cache || !s->ENABLE_PLAYBACK_CACHING) { - // Sleep during pause (after caching additional frames when paused) - // OR sleep when playback caching is disabled - std::this_thread::sleep_for(frame_duration / 2); + // If caching is disabled or no reader is assigned, wait briefly and retry + if (!settings->ENABLE_PLAYBACK_CACHING || !cache) { + std::this_thread::sleep_for(double_micro_sec(50000)); continue; + } - } else if (current_speed == 0) { - // Allow 'max frames' to increase when pause is detected (based on cache) - // To allow the cache to fill-up only on the initial pause. - should_pause_cache = true; + Timeline* timeline = static_cast(reader); + int64_t timeline_end = timeline->GetMaxFrame(); + int64_t playhead = requested_display_frame; + bool paused = (speed == 0); - // Calculate bytes per frame - int64_t bytes_per_frame = getBytes(reader->info.width, reader->info.height, - reader->info.sample_rate, reader->info.channels, - reader->info.fps.ToFloat()); - Timeline *t = (Timeline *) reader; - if (t->preview_width != reader->info.width || t->preview_height != reader->info.height) { - // If we have a different timeline preview size, use that instead (the preview - // window can be smaller, can thus reduce the bytes per frame) - bytes_per_frame = getBytes(t->preview_width, t->preview_height, - reader->info.sample_rate, reader->info.channels, - reader->info.fps.ToFloat()); + // Determine the effective direction: + // If speed != 0, dir = sign(speed). + // Otherwise (speed == 0), use last_dir to continue caching in the same direction. + int dir = (speed != 0 ? (speed > 0 ? 1 : -1) : last_dir); + + // On transition from paused (speed==0) to playing (speed!=0), reset window base. + if (!paused && last_paused) { + cache_start_index = playhead; + last_cached_index = playhead - dir; + } + last_paused = paused; + + // Calculate bytes needed for one frame in cache + int64_t bytes_per_frame = getBytes( + (timeline->preview_width ? timeline->preview_width : reader->info.width), + (timeline->preview_height ? timeline->preview_height : reader->info.height), + reader->info.sample_rate, + reader->info.channels, + reader->info.fps.ToFloat() + ); + + int64_t max_bytes = cache->GetMaxBytes(); + if (max_bytes <= 0 || bytes_per_frame <= 0) { + std::this_thread::sleep_for(double_micro_sec(50000)); + continue; + } + + int64_t capacity = max_bytes / bytes_per_frame; + if (capacity < 1) { + std::this_thread::sleep_for(double_micro_sec(50000)); + continue; + } + + // Determine how many frames ahead to cache based on settings + int64_t ahead_count = static_cast(capacity * settings->VIDEO_CACHE_PERCENT_AHEAD); + + // Handle user-initiated seek: reset window base if requested + bool user_seek = userSeeked; + if (user_seek) { + cache_start_index = playhead; + last_cached_index = playhead - dir; + userSeeked = false; + } + + // -------------------------------------------------------------------- + // PAUSED BRANCH: Continue caching in 'dir' without advancing playhead + // -------------------------------------------------------------------- + if (paused) { + // If the playhead is not currently in cache, clear cache and restart + if (!cache->Contains(playhead)) { + timeline->ClearAllCache(); + cache_start_index = playhead; + last_cached_index = playhead - dir; } - // Calculate # of frames on Timeline cache (when paused) - if (reader->GetCache() && reader->GetCache()->GetMaxBytes() > 0) { - // When paused, limit the cached frames to the following % of total cache size. - // This allows for us to leave some cache behind the plahead, and some in front of the playhead. - max_frames_ahead = (reader->GetCache()->GetMaxBytes() / bytes_per_frame) * s->VIDEO_CACHE_PERCENT_AHEAD; - if (max_frames_ahead > s->VIDEO_CACHE_MAX_FRAMES) { - // Ignore values that are too large, and default to a safer value - max_frames_ahead = s->VIDEO_CACHE_MAX_FRAMES; + // Build the cache window in the effective direction + if (dir > 0) { + // Forward: [cache_start_index ... cache_start_index + ahead_count] + int64_t window_end = cache_start_index + ahead_count; + window_end = std::min(window_end, timeline_end); + + // If all frames in this forward window are already cached, touch playhead and sleep + bool window_full = true; + for (int64_t frame = playhead; frame <= window_end; ++frame) { + if (!cache->Contains(frame)) { + window_full = false; + break; + } + } + if (window_full) { + cache->Touch(playhead); + std::this_thread::sleep_for(double_micro_sec(50000)); + continue; + } + + // Prefetch missing frames forward + int64_t start_index = std::max(last_cached_index + 1, cache_start_index); + for (int64_t frame = start_index; frame <= window_end; ++frame) { + if (threadShouldExit()) { + break; + } + if (!cache->Contains(frame)) { + try { + auto framePtr = reader->GetFrame(frame); + cache->Add(framePtr); + ++cached_frame_count; + } + catch (const OutOfBoundsFrame&) { + break; + } + } + else { + cache->Touch(frame); + } + last_cached_index = frame; + } + } + else { + // Backward: [cache_start_index - ahead_count ... cache_start_index] + int64_t window_begin = cache_start_index - ahead_count; + window_begin = std::max(window_begin, 1); + + // If all frames in this backward window are cached, touch playhead and sleep + bool window_full = true; + for (int64_t frame = playhead; frame >= window_begin; --frame) { + if (!cache->Contains(frame)) { + window_full = false; + break; + } + } + if (window_full) { + cache->Touch(playhead); + std::this_thread::sleep_for(double_micro_sec(50000)); + continue; + } + + // Prefetch missing frames backward + int64_t start_index = std::min(last_cached_index - 1, cache_start_index); + for (int64_t frame = start_index; frame >= window_begin; --frame) { + if (threadShouldExit()) { + break; + } + if (!cache->Contains(frame)) { + try { + auto framePtr = reader->GetFrame(frame); + cache->Add(framePtr); + ++cached_frame_count; + } + catch (const OutOfBoundsFrame&) { + break; + } + } + else { + cache->Touch(frame); + } + last_cached_index = frame; } } - // Overwrite the increment to our cache position - // to fully cache frames while paused (support forward and rewind caching) - // Use `last_speed` which is the last non-zero/non-paused speed - if (last_speed < 0) { - increment = -1; - } - - } else { - // normal playback - should_pause_cache = false; + // Sleep for a fraction of a frame interval to throttle CPU usage + int64_t pause_sleep = static_cast( + 1000000.0 / reader->info.fps.ToFloat() / 4.0 + ); + std::this_thread::sleep_for(double_micro_sec(pause_sleep)); + continue; } - // Always cache frames from the current display position to our maximum (based on the cache size). - // Frames which are already cached are basically free. Only uncached frames have a big CPU cost. - // By always looping through the expected frame range, we can fill-in missing frames caused by a - // fragmented cache object (i.e. the user clicking all over the timeline). The -1 is to always - // cache 1 frame previous to our current frame (to avoid our Seek method from clearing the cache). - int64_t starting_frame = std::min(current_display_frame, timeline_max_frame) - 1; - int64_t ending_frame = std::min(starting_frame + max_frames_ahead, timeline_max_frame); - - // Adjust ending frame for cache loop - if (increment < 0) { - // Reverse loop (if we are going backwards) - ending_frame = starting_frame - max_frames_ahead; + // -------------------------------------------------------------------- + // PLAYING BRANCH: Cache around the playhead in the playback direction + // -------------------------------------------------------------------- + if (dir > 0 && playhead > last_cached_index) { + // Forward playback has moved beyond the last cached frame: reset window + cache_start_index = playhead; + last_cached_index = playhead - 1; } - if (starting_frame < 1) { - // Don't allow negative frame number caching - starting_frame = 1; - } - if (ending_frame < 1) { - // Don't allow negative frame number caching - ending_frame = 1; + else if (dir < 0 && playhead < last_cached_index) { + // Backward playback has moved before the last cached frame: reset window + cache_start_index = playhead; + last_cached_index = playhead + 1; } - // Reset cache break-loop flag - should_break = false; + if (dir >= 0) { + // Forward caching: [playhead ... playhead + ahead_count] + int64_t window_end = playhead + ahead_count; + window_end = std::min(window_end, timeline_end); - // Loop through range of frames (and cache them) - for (int64_t cache_frame = starting_frame; cache_frame != (ending_frame + increment); cache_frame += increment) { - cached_frame_count++; - if (reader && reader->GetCache() && !reader->GetCache()->Contains(cache_frame)) { - try - { - // This frame is not already cached... so request it again (to force the creation & caching) - // This will also re-order the missing frame to the front of the cache - last_cached_frame = reader->GetFrame(cache_frame); + int64_t start_index = std::max(last_cached_index + 1, playhead); + for (int64_t frame = start_index; frame <= window_end; ++frame) { + if (threadShouldExit() || (userSeeked && !paused)) { + if (userSeeked) { + last_cached_index = playhead - 1; + userSeeked = false; + } + break; } - catch (const OutOfBoundsFrame & e) { } + if (!cache->Contains(frame)) { + try { + auto framePtr = reader->GetFrame(frame); + cache->Add(framePtr); + ++cached_frame_count; + } + catch (const OutOfBoundsFrame&) { + break; + } + } + else { + cache->Touch(frame); + } + last_cached_index = frame; } + } + else { + // Backward caching: [playhead - ahead_count ... playhead] + int64_t window_begin = playhead - ahead_count; + window_begin = std::max(window_begin, 1); - // Check if thread has stopped OR should_break is triggered - if (!is_playing || should_break || !s->ENABLE_PLAYBACK_CACHING) { - should_break = false; - break; + int64_t start_index = std::min(last_cached_index - 1, playhead); + for (int64_t frame = start_index; frame >= window_begin; --frame) { + if (threadShouldExit() || (userSeeked && !paused)) { + if (userSeeked) { + last_cached_index = playhead + 1; + userSeeked = false; + } + break; + } + if (!cache->Contains(frame)) { + try { + auto framePtr = reader->GetFrame(frame); + cache->Add(framePtr); + ++cached_frame_count; + } + catch (const OutOfBoundsFrame&) { + break; + } + } + else { + cache->Touch(frame); + } + last_cached_index = frame; } - } - // Sleep for a fraction of frame duration - std::this_thread::sleep_for(frame_duration / 2); - } - - return; + // Sleep for a fraction of a frame interval + int64_t quarter_us = static_cast( + 1000000.0 / reader->info.fps.ToFloat() / 4.0 + ); + std::this_thread::sleep_for(double_micro_sec(quarter_us)); + } } -} + + void VideoCacheThread::Seek(int64_t new_position, bool start_preroll) + { + if (start_preroll) { + userSeeked = true; + } + requested_display_frame = new_position; + } + + void VideoCacheThread::Seek(int64_t new_position) + { + Seek(new_position, false); + } + +} // namespace openshot diff --git a/src/Qt/VideoCacheThread.h b/src/Qt/VideoCacheThread.h index 63f40c36..99f49840 100644 --- a/src/Qt/VideoCacheThread.h +++ b/src/Qt/VideoCacheThread.h @@ -1,12 +1,12 @@ /** * @file - * @brief Source file for VideoCacheThread class + * @brief Header file for VideoCacheThread class * @author Jonathan Thomas * * @ref License */ -// Copyright (c) 2008-2019 OpenShot Studios, LLC +// Copyright (c) 2008-2025 OpenShot Studios, LLC // // SPDX-License-Identifier: LGPL-3.0-or-later @@ -23,66 +23,121 @@ namespace openshot using juce::Thread; using juce::WaitableEvent; + struct Frame; // forward declaration + /** - * @brief The video cache class. + * @brief Handles prefetching and caching of video/audio frames for smooth playback. + * + * This thread maintains a rolling cache window in the current playback direction (forward or backward). + * When paused, it continues to build the cache in the last known direction. On seek, it resets the window. */ - class VideoCacheThread : Thread + class VideoCacheThread : public Thread { - protected: - std::shared_ptr last_cached_frame; - int speed; - int last_speed; - bool is_playing; - int64_t requested_display_frame; - int64_t current_display_frame; - int64_t cached_frame_count = 0; - ReaderBase *reader; - int64_t min_frames_ahead; - int64_t max_frames_ahead; - int64_t timeline_max_frame; - bool should_pause_cache; - bool should_break; - - /// Constructor - VideoCacheThread(); - /// Destructor - ~VideoCacheThread(); - - /// Get the size in bytes of a frame (rough estimate) - int64_t getBytes(int width, int height, int sample_rate, int channels, float fps); - - /// Get Speed (The speed and direction to playback a reader (1=normal, 2=fast, 3=faster, -1=rewind, etc...) - int getSpeed() const { return speed; } - - /// Play the video - void Play(); - - /// Seek the reader to a particular frame number - void Seek(int64_t new_position); - - /// Seek the reader to a particular frame number and optionally start the pre-roll - void Seek(int64_t new_position, bool start_preroll); - - /// Set Speed (The speed and direction to playback a reader (1=normal, 2=fast, 3=faster, -1=rewind, etc...) - void setSpeed(int new_speed); - - /// Stop the audio playback - void Stop(); - - /// Start the thread - void run(); - - /// Set the current thread's reader - void Reader(ReaderBase *new_reader) { reader=new_reader; Play(); }; - - /// Parent class of VideoCacheThread - friend class PlayerPrivate; - friend class QtPlayer; - public: - /// Is cache ready for video/audio playback + /// Constructor: initializes variables and assumes forward direction on first launch. + VideoCacheThread(); + + /// Destructor. + ~VideoCacheThread() override; + + /// Returns true if enough frames are cached for playback (pre-roll completed). bool isReady(); + + /// Starts playback by setting is_playing to true. + void Play(); + + /// Stops playback by setting is_playing to false. + void Stop(); + + /** + * @brief Set the playback speed and direction. + * @param new_speed Positive values for forward play, negative for rewind, zero to pause. + * + * When new_speed != 0, last_speed and last_dir are updated. If new_speed is zero, + * last_dir remains unchanged so that pausing does not flip the cache direction. + */ + void setSpeed(int new_speed); + + /// Returns the current speed setting (1=normal, 2=fast, -1=rewind, etc.). + int getSpeed() const { return speed; } + + /** + * @brief Seek to a specific frame without pre-roll. + * @param new_position Frame index to seek to. + */ + void Seek(int64_t new_position); + + /** + * @brief Seek to a specific frame and optionally start pre-roll. + * @param new_position Frame index to seek to. + * @param start_preroll If true, signals the thread to rebuild cache from new_position. + */ + void Seek(int64_t new_position, bool start_preroll); + + /** + * @brief Assigns the ReaderBase source and begins caching. + * @param new_reader Pointer to the ReaderBase instance to cache from. + */ + void Reader(ReaderBase* new_reader) { reader = new_reader; Play(); } + + protected: + /** + * @brief Thread entry point: maintains and updates the cache window. + * + * This method runs continuously until threadShouldExit() returns true. It: + * 1. Computes playback direction (dir) based on speed or last_dir. + * 2. On seek or direction change, resets cache_start and last_cached. + * 3. When paused (speed == 0), continues caching in dir without advancing playhead. + * 4. When playing, caches in the direction of playback around the current playhead. + * 5. Sleeps for short intervals to throttle CPU usage. + */ + void run() override; + + /** + * @brief Estimate memory footprint of a single frame (video + audio). + * @param width Frame width in pixels. + * @param height Frame height in pixels. + * @param sample_rate Audio sample rate (e.g., 48000). + * @param channels Number of audio channels. + * @param fps Frames per second. + * @return Approximate size in bytes for storing one frame. + */ + int64_t getBytes(int width, int height, int sample_rate, int channels, float fps); + + //------------------------------------------------------------------------ + // Member variables + //------------------------------------------------------------------------ + + std::shared_ptr last_cached_frame; ///< Last frame pointer added to cache. + + int speed; ///< Current playback speed (0=paused, >0=forward, <0=backward). + int last_speed; ///< Last non-zero speed value (used to compute direction). + int last_dir; ///< Last playback direction: +1 for forward, -1 for backward. + + bool is_playing; ///< True when playback is running, false when stopped. + bool userSeeked; ///< True if a seek was requested (triggers cache reset). + + int64_t requested_display_frame; ///< Frame index requested by the user. + int64_t current_display_frame; ///< Currently displaying frame index (not used in caching). + int64_t cached_frame_count; ///< Number of frames currently in cache. + + int64_t min_frames_ahead; ///< Minimum number of frames to keep ahead in cache. + int64_t max_frames_ahead; ///< Maximum number of frames to keep ahead in cache. + int64_t timeline_max_frame; ///< Highest valid frame index in the timeline. + + bool should_pause_cache; ///< Flag to pause cache updates (not currently used). + bool should_break; ///< Internal flag to break out of loops (not currently used). + + ReaderBase* reader; ///< Pointer to the video/audio source (ReaderBase). + + /// Forces caching in a fixed direction when seeking into an uncached frame. + bool force_directional_cache; + + // Friends that may access protected members directly. + friend class PlayerPrivate; + friend class QtPlayer; }; -} + +} // namespace openshot #endif // OPENSHOT_VIDEO_CACHE_THREAD_H From ec81c1d1cdcfed163fff0550f64e510aa491de7a Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Jun 2025 16:46:04 -0500 Subject: [PATCH 3/6] Simplification and another small refactor of Run code (to make it more maintainable) --- src/Qt/VideoCacheThread.cpp | 232 ++++++++++-------------------------- src/Qt/VideoCacheThread.h | 14 +-- 2 files changed, 70 insertions(+), 176 deletions(-) diff --git a/src/Qt/VideoCacheThread.cpp b/src/Qt/VideoCacheThread.cpp index 737f5cbb..9a9ec463 100644 --- a/src/Qt/VideoCacheThread.cpp +++ b/src/Qt/VideoCacheThread.cpp @@ -96,6 +96,8 @@ namespace openshot using micro_sec = std::chrono::microseconds; using double_micro_sec = std::chrono::duration; + // last_cached_index: Index of the most recently cached frame. + // cache_start_index: Base index from which we build the window. int64_t last_cached_index = 0; int64_t cache_start_index = 0; bool last_paused = true; @@ -120,7 +122,7 @@ namespace openshot // Otherwise (speed == 0), use last_dir to continue caching in the same direction. int dir = (speed != 0 ? (speed > 0 ? 1 : -1) : last_dir); - // On transition from paused (speed==0) to playing (speed!=0), reset window base. + // On transition from paused (speed == 0) to playing (speed != 0), reset window base. if (!paused && last_paused) { cache_start_index = playhead; last_cached_index = playhead - dir; @@ -148,200 +150,94 @@ namespace openshot continue; } - // Determine how many frames ahead to cache based on settings + // Number of frames to keep ahead (or behind) based on settings int64_t ahead_count = static_cast(capacity * settings->VIDEO_CACHE_PERCENT_AHEAD); - // Handle user-initiated seek: reset window base if requested + // Handle user-initiated seek: always reset window base bool user_seek = userSeeked; if (user_seek) { cache_start_index = playhead; last_cached_index = playhead - dir; userSeeked = false; } + else if (!paused) { + // In playing mode, if playhead moves beyond last_cached, reset window + if ((dir > 0 && playhead > last_cached_index) || (dir < 0 && playhead < last_cached_index)) { + cache_start_index = playhead; + last_cached_index = playhead - dir; + } + } - // -------------------------------------------------------------------- - // PAUSED BRANCH: Continue caching in 'dir' without advancing playhead - // -------------------------------------------------------------------- + // ---------------------------------------- + // PAUSED MODE: Continue caching in 'dir' without advancing playhead + // ---------------------------------------- if (paused) { - // If the playhead is not currently in cache, clear cache and restart + // If the playhead is not in cache, clear and restart from playhead if (!cache->Contains(playhead)) { timeline->ClearAllCache(); cache_start_index = playhead; last_cached_index = playhead - dir; } + } - // Build the cache window in the effective direction - if (dir > 0) { - // Forward: [cache_start_index ... cache_start_index + ahead_count] - int64_t window_end = cache_start_index + ahead_count; - window_end = std::min(window_end, timeline_end); + // Compute window bounds based on dir + int64_t window_begin, window_end; + if (dir > 0) { + // Forward: [cache_start_index ... cache_start_index + ahead_count] + window_begin = cache_start_index; + window_end = cache_start_index + ahead_count; + } else { + // Backward: [cache_start_index - ahead_count ... cache_start_index] + window_begin = cache_start_index - ahead_count; + window_end = cache_start_index; + } - // If all frames in this forward window are already cached, touch playhead and sleep - bool window_full = true; - for (int64_t frame = playhead; frame <= window_end; ++frame) { - if (!cache->Contains(frame)) { - window_full = false; - break; - } - } - if (window_full) { - cache->Touch(playhead); - std::this_thread::sleep_for(double_micro_sec(50000)); - continue; - } + // Clamp to valid timeline range + window_begin = std::max(window_begin, 1); + window_end = std::min(window_end, timeline_end); - // Prefetch missing frames forward - int64_t start_index = std::max(last_cached_index + 1, cache_start_index); - for (int64_t frame = start_index; frame <= window_end; ++frame) { - if (threadShouldExit()) { - break; - } - if (!cache->Contains(frame)) { - try { - auto framePtr = reader->GetFrame(frame); - cache->Add(framePtr); - ++cached_frame_count; - } - catch (const OutOfBoundsFrame&) { - break; - } - } - else { - cache->Touch(frame); - } - last_cached_index = frame; + // Prefetch loop: start from just beyond last_cached_index toward window_end + int64_t next_frame = last_cached_index + dir; + bool window_full = true; + + while ((dir > 0 && next_frame <= window_end) || (dir < 0 && next_frame >= window_begin)) { + if (threadShouldExit()) { + break; + } + + // Interrupt if a new seek happened + if (userSeeked) { + break; + } + + if (!cache->Contains(next_frame)) { + // Missing frame: fetch and add to cache + try { + auto framePtr = reader->GetFrame(next_frame); + cache->Add(framePtr); + ++cached_frame_count; } + catch (const OutOfBoundsFrame&) { + break; + } + window_full = false; // We had to fetch at least one frame } else { - // Backward: [cache_start_index - ahead_count ... cache_start_index] - int64_t window_begin = cache_start_index - ahead_count; - window_begin = std::max(window_begin, 1); - - // If all frames in this backward window are cached, touch playhead and sleep - bool window_full = true; - for (int64_t frame = playhead; frame >= window_begin; --frame) { - if (!cache->Contains(frame)) { - window_full = false; - break; - } - } - if (window_full) { - cache->Touch(playhead); - std::this_thread::sleep_for(double_micro_sec(50000)); - continue; - } - - // Prefetch missing frames backward - int64_t start_index = std::min(last_cached_index - 1, cache_start_index); - for (int64_t frame = start_index; frame >= window_begin; --frame) { - if (threadShouldExit()) { - break; - } - if (!cache->Contains(frame)) { - try { - auto framePtr = reader->GetFrame(frame); - cache->Add(framePtr); - ++cached_frame_count; - } - catch (const OutOfBoundsFrame&) { - break; - } - } - else { - cache->Touch(frame); - } - last_cached_index = frame; - } + cache->Touch(next_frame); } - // Sleep for a fraction of a frame interval to throttle CPU usage - int64_t pause_sleep = static_cast( - 1000000.0 / reader->info.fps.ToFloat() / 4.0 - ); - std::this_thread::sleep_for(double_micro_sec(pause_sleep)); - continue; + last_cached_index = next_frame; + next_frame += dir; } - // -------------------------------------------------------------------- - // PLAYING BRANCH: Cache around the playhead in the playback direction - // -------------------------------------------------------------------- - if (dir > 0 && playhead > last_cached_index) { - // Forward playback has moved beyond the last cached frame: reset window - cache_start_index = playhead; - last_cached_index = playhead - 1; - } - else if (dir < 0 && playhead < last_cached_index) { - // Backward playback has moved before the last cached frame: reset window - cache_start_index = playhead; - last_cached_index = playhead + 1; + // In paused mode, if the entire window was already filled, touch playhead + if (paused && window_full) { + cache->Touch(playhead); } - if (dir >= 0) { - // Forward caching: [playhead ... playhead + ahead_count] - int64_t window_end = playhead + ahead_count; - window_end = std::min(window_end, timeline_end); - - int64_t start_index = std::max(last_cached_index + 1, playhead); - for (int64_t frame = start_index; frame <= window_end; ++frame) { - if (threadShouldExit() || (userSeeked && !paused)) { - if (userSeeked) { - last_cached_index = playhead - 1; - userSeeked = false; - } - break; - } - if (!cache->Contains(frame)) { - try { - auto framePtr = reader->GetFrame(frame); - cache->Add(framePtr); - ++cached_frame_count; - } - catch (const OutOfBoundsFrame&) { - break; - } - } - else { - cache->Touch(frame); - } - last_cached_index = frame; - } - } - else { - // Backward caching: [playhead - ahead_count ... playhead] - int64_t window_begin = playhead - ahead_count; - window_begin = std::max(window_begin, 1); - - int64_t start_index = std::min(last_cached_index - 1, playhead); - for (int64_t frame = start_index; frame >= window_begin; --frame) { - if (threadShouldExit() || (userSeeked && !paused)) { - if (userSeeked) { - last_cached_index = playhead + 1; - userSeeked = false; - } - break; - } - if (!cache->Contains(frame)) { - try { - auto framePtr = reader->GetFrame(frame); - cache->Add(framePtr); - ++cached_frame_count; - } - catch (const OutOfBoundsFrame&) { - break; - } - } - else { - cache->Touch(frame); - } - last_cached_index = frame; - } - } - - // Sleep for a fraction of a frame interval - int64_t quarter_us = static_cast( - 1000000.0 / reader->info.fps.ToFloat() / 4.0 - ); - std::this_thread::sleep_for(double_micro_sec(quarter_us)); + // Sleep a short fraction of a frame interval to throttle CPU usage + int64_t sleep_us = static_cast(1000000.0 / reader->info.fps.ToFloat() / 4.0); + std::this_thread::sleep_for(double_micro_sec(sleep_us)); } } diff --git a/src/Qt/VideoCacheThread.h b/src/Qt/VideoCacheThread.h index 99f49840..6c40659a 100644 --- a/src/Qt/VideoCacheThread.h +++ b/src/Qt/VideoCacheThread.h @@ -23,8 +23,6 @@ namespace openshot using juce::Thread; using juce::WaitableEvent; - struct Frame; // forward declaration - /** * @brief Handles prefetching and caching of video/audio frames for smooth playback. * @@ -34,7 +32,7 @@ namespace openshot class VideoCacheThread : public Thread { public: - /// Constructor: initializes variables and assumes forward direction on first launch. + /// Constructor: initializes member variables and assumes forward direction on first launch. VideoCacheThread(); /// Destructor. @@ -80,12 +78,16 @@ namespace openshot */ void Reader(ReaderBase* new_reader) { reader = new_reader; Play(); } + // Friend classes that may access protected members directly. + friend class PlayerPrivate; + friend class QtPlayer; + protected: /** * @brief Thread entry point: maintains and updates the cache window. * * This method runs continuously until threadShouldExit() returns true. It: - * 1. Computes playback direction (dir) based on speed or last_dir. + * 1. Computes effective playback direction (dir) based on speed or last_dir. * 2. On seek or direction change, resets cache_start and last_cached. * 3. When paused (speed == 0), continues caching in dir without advancing playhead. * 4. When playing, caches in the direction of playback around the current playhead. @@ -132,10 +134,6 @@ namespace openshot /// Forces caching in a fixed direction when seeking into an uncached frame. bool force_directional_cache; - - // Friends that may access protected members directly. - friend class PlayerPrivate; - friend class QtPlayer; }; } // namespace openshot From fd91892fea0ffa1346937e552059a05e6def8f43 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Jun 2025 17:49:56 -0500 Subject: [PATCH 4/6] More tweaks to keep the cache window centered on the playhead, so different speeds (- and +) still result in moving cache window. --- src/Qt/VideoCacheThread.cpp | 112 ++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 62 deletions(-) diff --git a/src/Qt/VideoCacheThread.cpp b/src/Qt/VideoCacheThread.cpp index 9a9ec463..e850b340 100644 --- a/src/Qt/VideoCacheThread.cpp +++ b/src/Qt/VideoCacheThread.cpp @@ -18,6 +18,7 @@ #include "Timeline.h" #include #include +#include namespace openshot { @@ -96,11 +97,8 @@ namespace openshot using micro_sec = std::chrono::microseconds; using double_micro_sec = std::chrono::duration; - // last_cached_index: Index of the most recently cached frame. - // cache_start_index: Base index from which we build the window. + // Index of the most recently cached frame; starts “behind” the playhead. int64_t last_cached_index = 0; - int64_t cache_start_index = 0; - bool last_paused = true; while (!threadShouldExit()) { Settings* settings = Settings::Instance(); @@ -117,19 +115,21 @@ namespace openshot int64_t playhead = requested_display_frame; bool paused = (speed == 0); - // Determine the effective direction: - // If speed != 0, dir = sign(speed). - // Otherwise (speed == 0), use last_dir to continue caching in the same direction. + // Determine effective direction: use speed if non-zero, otherwise keep last_dir. int dir = (speed != 0 ? (speed > 0 ? 1 : -1) : last_dir); - // On transition from paused (speed == 0) to playing (speed != 0), reset window base. - if (!paused && last_paused) { - cache_start_index = playhead; - last_cached_index = playhead - dir; + // On any non-seek iteration, update last_dir if speed changed from zero + if (speed != 0) { + last_dir = dir; } - last_paused = paused; - // Calculate bytes needed for one frame in cache + // Handle user-initiated seek: reset last_cached_index to just behind playhead. + if (userSeeked) { + last_cached_index = playhead - dir; + userSeeked = false; + } + + // Determine how many frames ahead/behind to cache based on settings & memory int64_t bytes_per_frame = getBytes( (timeline->preview_width ? timeline->preview_width : reader->info.width), (timeline->preview_height ? timeline->preview_height : reader->info.height), @@ -153,59 +153,45 @@ namespace openshot // Number of frames to keep ahead (or behind) based on settings int64_t ahead_count = static_cast(capacity * settings->VIDEO_CACHE_PERCENT_AHEAD); - // Handle user-initiated seek: always reset window base - bool user_seek = userSeeked; - if (user_seek) { - cache_start_index = playhead; - last_cached_index = playhead - dir; - userSeeked = false; - } - else if (!paused) { - // In playing mode, if playhead moves beyond last_cached, reset window - if ((dir > 0 && playhead > last_cached_index) || (dir < 0 && playhead < last_cached_index)) { - cache_start_index = playhead; - last_cached_index = playhead - dir; - } - } - - // ---------------------------------------- - // PAUSED MODE: Continue caching in 'dir' without advancing playhead - // ---------------------------------------- - if (paused) { - // If the playhead is not in cache, clear and restart from playhead - if (!cache->Contains(playhead)) { - timeline->ClearAllCache(); - cache_start_index = playhead; - last_cached_index = playhead - dir; - } - } - - // Compute window bounds based on dir - int64_t window_begin, window_end; - if (dir > 0) { - // Forward: [cache_start_index ... cache_start_index + ahead_count] - window_begin = cache_start_index; - window_end = cache_start_index + ahead_count; - } else { - // Backward: [cache_start_index - ahead_count ... cache_start_index] - window_begin = cache_start_index - ahead_count; - window_end = cache_start_index; - } + // Compute window bounds around playhead each iteration: + // - If moving forward (dir > 0): [playhead ... playhead + ahead_count] + // - If moving backward (dir < 0): [playhead - ahead_count ... playhead] + int64_t window_begin = (dir > 0) + ? playhead + : (playhead - ahead_count); + int64_t window_end = (dir > 0) + ? (playhead + ahead_count) + : playhead; // Clamp to valid timeline range window_begin = std::max(window_begin, 1); window_end = std::min(window_end, timeline_end); - // Prefetch loop: start from just beyond last_cached_index toward window_end - int64_t next_frame = last_cached_index + dir; - bool window_full = true; + // If we're paused and the playhead moves outside cache, clear & rebuild + if (paused && !cache->Contains(playhead)) { + timeline->ClearAllCache(); + last_cached_index = playhead - dir; + } - while ((dir > 0 && next_frame <= window_end) || (dir < 0 && next_frame >= window_begin)) { + // If playing, ensure last_cached_index is within one step of window + // If it's already beyond the window, reset so caching continues from playhead + bool outside_window = (dir > 0 && last_cached_index > window_end) || + (dir < 0 && last_cached_index < window_begin); + if (!paused && outside_window) { + last_cached_index = playhead - dir; + } + + // Prefetch frames from last_cached_index + dir up to window_end (or down to window_begin) + int64_t next_frame = last_cached_index + dir; + bool window_full = true; + + while ((dir > 0 && next_frame <= window_end) || + (dir < 0 && next_frame >= window_begin)) + { if (threadShouldExit()) { break; } - - // Interrupt if a new seek happened + // If a new seek arrives mid-cache, break and start over next loop if (userSeeked) { break; } @@ -220,23 +206,25 @@ namespace openshot catch (const OutOfBoundsFrame&) { break; } - window_full = false; // We had to fetch at least one frame + window_full = false; } else { cache->Touch(next_frame); } last_cached_index = next_frame; - next_frame += dir; + next_frame += dir; } - // In paused mode, if the entire window was already filled, touch playhead + // If paused and the window was already filled, just touch playhead to keep it fresh if (paused && window_full) { cache->Touch(playhead); } - // Sleep a short fraction of a frame interval to throttle CPU usage - int64_t sleep_us = static_cast(1000000.0 / reader->info.fps.ToFloat() / 4.0); + // Short sleep to throttle CPU (quarter-frame interval) + int64_t sleep_us = static_cast( + 1000000.0 / reader->info.fps.ToFloat() / 4.0 + ); std::this_thread::sleep_for(double_micro_sec(sleep_us)); } } From 6c39b0f8e58cbde5d28b8631c70d19bc234b09c7 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Jun 2025 19:36:05 -0500 Subject: [PATCH 5/6] Another large refactor of our primary cache thread, to split out many functions which we can test in our unit tests. --- src/Qt/VideoCacheThread.cpp | 397 +++++++++++++++++++++--------------- src/Qt/VideoCacheThread.h | 162 +++++++++------ 2 files changed, 334 insertions(+), 225 deletions(-) diff --git a/src/Qt/VideoCacheThread.cpp b/src/Qt/VideoCacheThread.cpp index e850b340..4697ffe8 100644 --- a/src/Qt/VideoCacheThread.cpp +++ b/src/Qt/VideoCacheThread.cpp @@ -27,7 +27,7 @@ namespace openshot : Thread("video-cache") , speed(0) , last_speed(1) - , last_dir(1) // Assume forward (+1) on first launch + , last_dir(1) // assume forward (+1) on first launch , is_playing(false) , userSeeked(false) , requested_display_frame(1) @@ -36,10 +36,9 @@ namespace openshot , min_frames_ahead(4) , max_frames_ahead(8) , timeline_max_frame(0) - , should_pause_cache(false) - , should_break(false) , reader(nullptr) , force_directional_cache(false) + , last_cached_index(0) { } @@ -48,37 +47,13 @@ namespace openshot { } - // Seek the reader to a particular frame number - void VideoCacheThread::setSpeed(int new_speed) - { - // Only update last_speed and last_dir when new_speed is non-zero. - if (new_speed != 0) { - last_speed = new_speed; - last_dir = (new_speed > 0 ? 1 : -1); - } - speed = new_speed; - } - - // Get the size in bytes of a frame (rough estimate) - int64_t VideoCacheThread::getBytes(int width, int height, int sample_rate, int channels, float fps) - { - // Estimate memory for RGBA video frame - int64_t bytes = static_cast(width) * height * sizeof(char) * 4; - - // Approximate audio memory: (sample_rate * channels) samples per second, - // divided across fps frames, each sample is sizeof(float). - bytes += ((sample_rate * channels) / fps) * sizeof(float); - - return bytes; - } - // Play the video void VideoCacheThread::Play() { is_playing = true; } - // Stop the audio + // Stop the video void VideoCacheThread::Stop() { is_playing = false; @@ -87,146 +62,31 @@ namespace openshot // Is cache ready for playback (pre-roll) bool VideoCacheThread::isReady() { - // Return true when pre-roll has cached at least min_frames_ahead frames. return (cached_frame_count > min_frames_ahead); } - // Start the thread - void VideoCacheThread::run() + void VideoCacheThread::setSpeed(int new_speed) { - using micro_sec = std::chrono::microseconds; - using double_micro_sec = std::chrono::duration; - - // Index of the most recently cached frame; starts “behind” the playhead. - int64_t last_cached_index = 0; - - while (!threadShouldExit()) { - Settings* settings = Settings::Instance(); - CacheBase* cache = reader ? reader->GetCache() : nullptr; - - // If caching is disabled or no reader is assigned, wait briefly and retry - if (!settings->ENABLE_PLAYBACK_CACHING || !cache) { - std::this_thread::sleep_for(double_micro_sec(50000)); - continue; - } - - Timeline* timeline = static_cast(reader); - int64_t timeline_end = timeline->GetMaxFrame(); - int64_t playhead = requested_display_frame; - bool paused = (speed == 0); - - // Determine effective direction: use speed if non-zero, otherwise keep last_dir. - int dir = (speed != 0 ? (speed > 0 ? 1 : -1) : last_dir); - - // On any non-seek iteration, update last_dir if speed changed from zero - if (speed != 0) { - last_dir = dir; - } - - // Handle user-initiated seek: reset last_cached_index to just behind playhead. - if (userSeeked) { - last_cached_index = playhead - dir; - userSeeked = false; - } - - // Determine how many frames ahead/behind to cache based on settings & memory - int64_t bytes_per_frame = getBytes( - (timeline->preview_width ? timeline->preview_width : reader->info.width), - (timeline->preview_height ? timeline->preview_height : reader->info.height), - reader->info.sample_rate, - reader->info.channels, - reader->info.fps.ToFloat() - ); - - int64_t max_bytes = cache->GetMaxBytes(); - if (max_bytes <= 0 || bytes_per_frame <= 0) { - std::this_thread::sleep_for(double_micro_sec(50000)); - continue; - } - - int64_t capacity = max_bytes / bytes_per_frame; - if (capacity < 1) { - std::this_thread::sleep_for(double_micro_sec(50000)); - continue; - } - - // Number of frames to keep ahead (or behind) based on settings - int64_t ahead_count = static_cast(capacity * settings->VIDEO_CACHE_PERCENT_AHEAD); - - // Compute window bounds around playhead each iteration: - // - If moving forward (dir > 0): [playhead ... playhead + ahead_count] - // - If moving backward (dir < 0): [playhead - ahead_count ... playhead] - int64_t window_begin = (dir > 0) - ? playhead - : (playhead - ahead_count); - int64_t window_end = (dir > 0) - ? (playhead + ahead_count) - : playhead; - - // Clamp to valid timeline range - window_begin = std::max(window_begin, 1); - window_end = std::min(window_end, timeline_end); - - // If we're paused and the playhead moves outside cache, clear & rebuild - if (paused && !cache->Contains(playhead)) { - timeline->ClearAllCache(); - last_cached_index = playhead - dir; - } - - // If playing, ensure last_cached_index is within one step of window - // If it's already beyond the window, reset so caching continues from playhead - bool outside_window = (dir > 0 && last_cached_index > window_end) || - (dir < 0 && last_cached_index < window_begin); - if (!paused && outside_window) { - last_cached_index = playhead - dir; - } - - // Prefetch frames from last_cached_index + dir up to window_end (or down to window_begin) - int64_t next_frame = last_cached_index + dir; - bool window_full = true; - - while ((dir > 0 && next_frame <= window_end) || - (dir < 0 && next_frame >= window_begin)) - { - if (threadShouldExit()) { - break; - } - // If a new seek arrives mid-cache, break and start over next loop - if (userSeeked) { - break; - } - - if (!cache->Contains(next_frame)) { - // Missing frame: fetch and add to cache - try { - auto framePtr = reader->GetFrame(next_frame); - cache->Add(framePtr); - ++cached_frame_count; - } - catch (const OutOfBoundsFrame&) { - break; - } - window_full = false; - } - else { - cache->Touch(next_frame); - } - - last_cached_index = next_frame; - next_frame += dir; - } - - // If paused and the window was already filled, just touch playhead to keep it fresh - if (paused && window_full) { - cache->Touch(playhead); - } - - // Short sleep to throttle CPU (quarter-frame interval) - int64_t sleep_us = static_cast( - 1000000.0 / reader->info.fps.ToFloat() / 4.0 - ); - std::this_thread::sleep_for(double_micro_sec(sleep_us)); + // Only update last_speed and last_dir when new_speed != 0 + if (new_speed != 0) { + last_speed = new_speed; + last_dir = (new_speed > 0 ? 1 : -1); } + speed = new_speed; + } + + // Get the size in bytes of a frame (rough estimate) + int64_t VideoCacheThread::getBytes(int width, + int height, + int sample_rate, + int channels, + float fps) + { + // RGBA video frame + int64_t bytes = static_cast(width) * height * sizeof(char) * 4; + // Approximate audio: (sample_rate * channels)/fps samples per frame + bytes += ((sample_rate * channels) / fps) * sizeof(float); + return bytes; } void VideoCacheThread::Seek(int64_t new_position, bool start_preroll) @@ -242,4 +102,215 @@ namespace openshot Seek(new_position, false); } + int VideoCacheThread::computeDirection() const + { + // If speed ≠ 0, use its sign; if speed==0, keep last_dir + return (speed != 0 ? (speed > 0 ? 1 : -1) : last_dir); + } + + void VideoCacheThread::handleUserSeek(int64_t playhead, int dir) + { + // Place last_cached_index just “behind” playhead in the given dir + last_cached_index = playhead - dir; + } + + bool VideoCacheThread::clearCacheIfPaused(int64_t playhead, + bool paused, + CacheBase* cache) + { + if (paused && !cache->Contains(playhead)) { + // If paused and playhead not in cache, clear everything + Timeline* timeline = static_cast(reader); + timeline->ClearAllCache(); + return true; + } + return false; + } + + void VideoCacheThread::computeWindowBounds(int64_t playhead, + int dir, + int64_t ahead_count, + int64_t timeline_end, + int64_t& window_begin, + int64_t& window_end) const + { + if (dir > 0) { + // Forward window: [playhead ... playhead + ahead_count] + window_begin = playhead; + window_end = playhead + ahead_count; + } + else { + // Backward window: [playhead - ahead_count ... playhead] + window_begin = playhead - ahead_count; + window_end = playhead; + } + // Clamp to [1 ... timeline_end] + window_begin = std::max(window_begin, 1); + window_end = std::min(window_end, timeline_end); + } + + bool VideoCacheThread::prefetchWindow(CacheBase* cache, + int64_t window_begin, + int64_t window_end, + int dir, + ReaderBase* reader) + { + bool window_full = true; + int64_t next_frame = last_cached_index + dir; + + // Advance from last_cached_index toward window boundary + while ((dir > 0 && next_frame <= window_end) || + (dir < 0 && next_frame >= window_begin)) + { + if (threadShouldExit()) { + break; + } + // If a Seek was requested mid-caching, bail out immediately + if (userSeeked) { + break; + } + + if (!cache->Contains(next_frame)) { + // Frame missing, fetch and add + try { + auto framePtr = reader->GetFrame(next_frame); + cache->Add(framePtr); + ++cached_frame_count; + } + catch (const OutOfBoundsFrame&) { + break; + } + window_full = false; + } + else { + cache->Touch(next_frame); + } + + last_cached_index = next_frame; + next_frame += dir; + } + + return window_full; + } + + void VideoCacheThread::run() + { + using micro_sec = std::chrono::microseconds; + using double_micro_sec = std::chrono::duration; + + while (!threadShouldExit()) { + Settings* settings = Settings::Instance(); + CacheBase* cache = reader ? reader->GetCache() : nullptr; + + // If caching disabled or no reader, sleep briefly + if (!settings->ENABLE_PLAYBACK_CACHING || !cache) { + std::this_thread::sleep_for(double_micro_sec(50000)); + continue; + } + + Timeline* timeline = static_cast(reader); + int64_t timeline_end = timeline->GetMaxFrame(); + int64_t playhead = requested_display_frame; + bool paused = (speed == 0); + + // Compute effective direction (±1) + int dir = computeDirection(); + if (speed != 0) { + last_dir = dir; + } + + // If a seek was requested, reset last_cached_index + if (userSeeked) { + handleUserSeek(playhead, dir); + userSeeked = false; + } + else if (!paused) { + // Check if last_cached_index drifted outside the new window; if so, reset it + int64_t bytes_per_frame = getBytes( + (timeline->preview_width ? timeline->preview_width : reader->info.width), + (timeline->preview_height ? timeline->preview_height : reader->info.height), + reader->info.sample_rate, + reader->info.channels, + reader->info.fps.ToFloat() + ); + int64_t max_bytes = cache->GetMaxBytes(); + if (max_bytes > 0 && bytes_per_frame > 0) { + int64_t capacity = max_bytes / bytes_per_frame; + if (capacity >= 1) { + int64_t ahead_count = static_cast(capacity * + settings->VIDEO_CACHE_PERCENT_AHEAD); + int64_t window_begin, window_end; + computeWindowBounds(playhead, + dir, + ahead_count, + timeline_end, + window_begin, + window_end); + + bool outside_window = + (dir > 0 && last_cached_index > window_end) || + (dir < 0 && last_cached_index < window_begin); + if (outside_window) { + handleUserSeek(playhead, dir); + } + } + } + } + + // Recompute capacity & ahead_count now that we’ve possibly updated last_cached_index + int64_t bytes_per_frame = getBytes( + (timeline->preview_width ? timeline->preview_width : reader->info.width), + (timeline->preview_height ? timeline->preview_height : reader->info.height), + reader->info.sample_rate, + reader->info.channels, + reader->info.fps.ToFloat() + ); + int64_t max_bytes = cache->GetMaxBytes(); + if (max_bytes <= 0 || bytes_per_frame <= 0) { + std::this_thread::sleep_for(double_micro_sec(50000)); + continue; + } + int64_t capacity = max_bytes / bytes_per_frame; + if (capacity < 1) { + std::this_thread::sleep_for(double_micro_sec(50000)); + continue; + } + int64_t ahead_count = static_cast(capacity * + settings->VIDEO_CACHE_PERCENT_AHEAD); + + // If paused and playhead is no longer in cache, clear everything + bool did_clear = clearCacheIfPaused(playhead, paused, cache); + if (did_clear) { + handleUserSeek(playhead, dir); + } + + // Compute the current caching window + int64_t window_begin, window_end; + computeWindowBounds(playhead, + dir, + ahead_count, + timeline_end, + window_begin, + window_end); + + // Attempt to fill any missing frames in that window + bool window_full = prefetchWindow(cache, + window_begin, + window_end, + dir, + reader); + + // If paused and window was already full, keep playhead fresh + if (paused && window_full) { + cache->Touch(playhead); + } + + // Sleep a short fraction of a frame interval + int64_t sleep_us = static_cast( + 1000000.0 / reader->info.fps.ToFloat() / 4.0 + ); + std::this_thread::sleep_for(double_micro_sec(sleep_us)); + } + } + } // namespace openshot diff --git a/src/Qt/VideoCacheThread.h b/src/Qt/VideoCacheThread.h index 6c40659a..fc2c944f 100644 --- a/src/Qt/VideoCacheThread.h +++ b/src/Qt/VideoCacheThread.h @@ -17,123 +17,161 @@ #include #include +#include namespace openshot { using juce::Thread; - using juce::WaitableEvent; /** * @brief Handles prefetching and caching of video/audio frames for smooth playback. * - * This thread maintains a rolling cache window in the current playback direction (forward or backward). - * When paused, it continues to build the cache in the last known direction. On seek, it resets the window. + * This thread continuously maintains a “window” of cached frames in the current playback + * direction (forward or backward). When paused, it continues to fill that same window; + * when seeking, it resets to cache around the new position. */ class VideoCacheThread : public Thread { public: /// Constructor: initializes member variables and assumes forward direction on first launch. VideoCacheThread(); - - /// Destructor. ~VideoCacheThread() override; - /// Returns true if enough frames are cached for playback (pre-roll completed). + /// @return True if at least min_frames_ahead frames have been cached. bool isReady(); - /// Starts playback by setting is_playing to true. + /// Set is_playing = true, so run() will begin caching/playback. void Play(); - /// Stops playback by setting is_playing to false. + /// Set is_playing = false, effectively pausing playback (caching still runs). void Stop(); /** - * @brief Set the playback speed and direction. - * @param new_speed Positive values for forward play, negative for rewind, zero to pause. + * @brief Set playback speed/direction. Positive = forward, negative = rewind, zero = pause. + * @param new_speed * - * When new_speed != 0, last_speed and last_dir are updated. If new_speed is zero, - * last_dir remains unchanged so that pausing does not flip the cache direction. + * If new_speed != 0, last_speed and last_dir are updated. + * If new_speed == 0, last_dir is left unchanged so that pausing does not flip direction. */ void setSpeed(int new_speed); - /// Returns the current speed setting (1=normal, 2=fast, -1=rewind, etc.). + /// @return The current speed (1=normal, 2=fast, –1=rewind, etc.) int getSpeed() const { return speed; } - /** - * @brief Seek to a specific frame without pre-roll. - * @param new_position Frame index to seek to. - */ + /// Seek to a specific frame (no preroll). void Seek(int64_t new_position); /** - * @brief Seek to a specific frame and optionally start pre-roll. - * @param new_position Frame index to seek to. - * @param start_preroll If true, signals the thread to rebuild cache from new_position. + * @brief Seek to a specific frame and optionally start a preroll (cache reset). + * @param new_position Frame index to jump to. + * @param start_preroll If true, forces cache to rebuild around new_position. */ void Seek(int64_t new_position, bool start_preroll); /** - * @brief Assigns the ReaderBase source and begins caching. - * @param new_reader Pointer to the ReaderBase instance to cache from. + * @brief Attach a ReaderBase (e.g. Timeline, FFmpegReader) and begin caching. + * @param new_reader */ void Reader(ReaderBase* new_reader) { reader = new_reader; Play(); } - // Friend classes that may access protected members directly. - friend class PlayerPrivate; - friend class QtPlayer; - protected: - /** - * @brief Thread entry point: maintains and updates the cache window. - * - * This method runs continuously until threadShouldExit() returns true. It: - * 1. Computes effective playback direction (dir) based on speed or last_dir. - * 2. On seek or direction change, resets cache_start and last_cached. - * 3. When paused (speed == 0), continues caching in dir without advancing playhead. - * 4. When playing, caches in the direction of playback around the current playhead. - * 5. Sleeps for short intervals to throttle CPU usage. - */ + /// Thread entry point: loops until threadShouldExit() is true. void run() override; /** - * @brief Estimate memory footprint of a single frame (video + audio). - * @param width Frame width in pixels. - * @param height Frame height in pixels. - * @param sample_rate Audio sample rate (e.g., 48000). - * @param channels Number of audio channels. - * @param fps Frames per second. - * @return Approximate size in bytes for storing one frame. + * @brief Estimate memory usage for a single frame (video + audio). + * @param width Frame width (pixels) + * @param height Frame height (pixels) + * @param sample_rate Audio sample rate (e.g. 48000) + * @param channels Number of audio channels + * @param fps Frames per second + * @return Approximate size in bytes for one frame */ int64_t getBytes(int width, int height, int sample_rate, int channels, float fps); - //------------------------------------------------------------------------ - // Member variables - //------------------------------------------------------------------------ + //---------- Helper functions, broken out for clarity & unit testing ---------- + + /// @return Effective playback direction (+1 forward, –1 backward), preserving last_dir if speed == 0. + int computeDirection() const; + + /** + * @brief If userSeeked is true, reset last_cached_index just behind the playhead. + * @param playhead Current requested_display_frame + * @param dir Effective direction (±1) + */ + void handleUserSeek(int64_t playhead, int dir); + + /** + * @brief When paused and playhead is outside current cache, clear all frames. + * @param playhead Current requested_display_frame + * @param paused True if speed == 0 + * @param cache Pointer to CacheBase + * @return True if ClearAllCache() was invoked. + */ + bool clearCacheIfPaused(int64_t playhead, bool paused, CacheBase* cache); + + /** + * @brief Compute the “window” of frames to cache around playhead. + * @param playhead Current requested_display_frame + * @param dir Effective direction (±1) + * @param ahead_count Number of frames ahead/back to cache + * @param timeline_end Last valid frame index + * @param[out] window_begin Lower bound (inclusive) of caching window + * @param[out] window_end Upper bound (inclusive) of caching window + * + * If dir > 0: window = [playhead ... playhead + ahead_count] + * If dir < 0: window = [playhead – ahead_count ... playhead] + * Always clamps to [1 ... timeline_end]. + */ + void computeWindowBounds(int64_t playhead, + int dir, + int64_t ahead_count, + int64_t timeline_end, + int64_t& window_begin, + int64_t& window_end) const; + + /** + * @brief Prefetch all missing frames in [window_begin ... window_end] or [window_end ... window_begin]. + * @param cache Pointer to CacheBase + * @param window_begin Inclusive lower bound of the window + * @param window_end Inclusive upper bound of the window + * @param dir Effective direction (±1) + * @param reader Pointer to ReaderBase to call GetFrame() + * @return True if the window was already full (no new frames added) + * + * Internally, this method iterates from last_cached_index + dir toward window_end (or window_begin) + * and calls GetFrame()/Add() for each missing frame until hitting the window boundary or an OOB. + * It also breaks early if threadShouldExit() or userSeeked becomes true. + */ + bool prefetchWindow(CacheBase* cache, + int64_t window_begin, + int64_t window_end, + int dir, + ReaderBase* reader); + + //---------- Internal state ---------- std::shared_ptr last_cached_frame; ///< Last frame pointer added to cache. - int speed; ///< Current playback speed (0=paused, >0=forward, <0=backward). - int last_speed; ///< Last non-zero speed value (used to compute direction). - int last_dir; ///< Last playback direction: +1 for forward, -1 for backward. + int speed; ///< Current playback speed (0=paused, >0 forward, <0 backward). + int last_speed; ///< Last non-zero speed (for tracking). + int last_dir; ///< Last direction sign (+1 forward, –1 backward). - bool is_playing; ///< True when playback is running, false when stopped. - bool userSeeked; ///< True if a seek was requested (triggers cache reset). + bool is_playing; ///< True if playback is “running” (affects thread loop, not caching). + bool userSeeked; ///< True if Seek(..., true) was called (forces a cache reset). - int64_t requested_display_frame; ///< Frame index requested by the user. - int64_t current_display_frame; ///< Currently displaying frame index (not used in caching). - int64_t cached_frame_count; ///< Number of frames currently in cache. + int64_t requested_display_frame; ///< Frame index the user requested. + int64_t current_display_frame; ///< Currently displayed frame (unused here, reserved). + int64_t cached_frame_count; ///< Count of frames currently added to cache. - int64_t min_frames_ahead; ///< Minimum number of frames to keep ahead in cache. - int64_t max_frames_ahead; ///< Maximum number of frames to keep ahead in cache. + int64_t min_frames_ahead; ///< Minimum number of frames considered “ready” (pre-roll). + int64_t max_frames_ahead; ///< Maximum frames to attempt to cache (mem capped). int64_t timeline_max_frame; ///< Highest valid frame index in the timeline. - bool should_pause_cache; ///< Flag to pause cache updates (not currently used). - bool should_break; ///< Internal flag to break out of loops (not currently used). + ReaderBase* reader; ///< The source reader (e.g., Timeline, FFmpegReader). + bool force_directional_cache; ///< (Reserved for future use). - ReaderBase* reader; ///< Pointer to the video/audio source (ReaderBase). - - /// Forces caching in a fixed direction when seeking into an uncached frame. - bool force_directional_cache; + int64_t last_cached_index; ///< Index of the most recently cached frame. }; } // namespace openshot From 364fb375659c5501864941f0d3c6a2c1899753a4 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Jun 2025 22:24:34 -0500 Subject: [PATCH 6/6] Unit tests for new VideoCacheThread, to test all helper methods and ensure our general logic is correct (caching forward and backwards) --- tests/CMakeLists.txt | 1 + tests/VideoCacheThread.cpp | 235 +++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 tests/VideoCacheThread.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 246a5c08..c24b1f61 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -42,6 +42,7 @@ set(OPENSHOT_TESTS Settings SphericalMetadata Timeline + VideoCacheThread # Effects ColorMap ChromaKey diff --git a/tests/VideoCacheThread.cpp b/tests/VideoCacheThread.cpp new file mode 100644 index 00000000..74bd5c62 --- /dev/null +++ b/tests/VideoCacheThread.cpp @@ -0,0 +1,235 @@ +/** + * @file + * @brief Unit tests for VideoCacheThread helper methods + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2025 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include +#include "openshot_catch.h" + +#include "Qt/VideoCacheThread.h" +#include "CacheMemory.h" +#include "ReaderBase.h" +#include "Frame.h" +#include "Settings.h" +#include "FFmpegReader.h" +#include "Timeline.h" + +using namespace openshot; + +// ---------------------------------------------------------------------------- +// TestableVideoCacheThread: expose protected/internal members for testing +// +class TestableVideoCacheThread : public VideoCacheThread { +public: + using VideoCacheThread::computeDirection; + using VideoCacheThread::computeWindowBounds; + using VideoCacheThread::clearCacheIfPaused; + using VideoCacheThread::prefetchWindow; + using VideoCacheThread::handleUserSeek; + + int64_t getLastCachedIndex() const { return last_cached_index; } + void setLastCachedIndex(int64_t v) { last_cached_index = v; } + void setLastDir(int d) { last_dir = d; } + void forceUserSeekFlag() { userSeeked = true; } +}; + +// ---------------------------------------------------------------------------- +// TESTS +// ---------------------------------------------------------------------------- + +TEST_CASE("computeDirection: respects speed and last_dir", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + + // Default: speed=0, last_dir initialized to +1 + CHECK(thread.computeDirection() == 1); + + // Positive speed + thread.setSpeed(3); + CHECK(thread.computeDirection() == 1); + CHECK(thread.getSpeed() == 3); + + // Negative speed + thread.setSpeed(-2); + CHECK(thread.computeDirection() == -1); + CHECK(thread.getSpeed() == -2); + + // Pause should preserve last_dir = -1 + thread.setSpeed(0); + CHECK(thread.computeDirection() == -1); + + // Manually override last_dir to +1, then pause + thread.setLastDir(1); + thread.setSpeed(0); + CHECK(thread.computeDirection() == 1); +} + +TEST_CASE("computeWindowBounds: forward and backward bounds, clamped", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + int64_t wb, we; + + // Forward direction, normal case + thread.computeWindowBounds(/*playhead=*/10, /*dir=*/1, /*ahead_count=*/5, /*timeline_end=*/50, wb, we); + CHECK(wb == 10); + CHECK(we == 15); + + // Forward direction, at timeline edge + thread.computeWindowBounds(/*playhead=*/47, /*dir=*/1, /*ahead_count=*/10, /*timeline_end=*/50, wb, we); + CHECK(wb == 47); + CHECK(we == 50); // clamped to 50 + + // Backward direction, normal + thread.computeWindowBounds(/*playhead=*/20, /*dir=*/-1, /*ahead_count=*/7, /*timeline_end=*/100, wb, we); + CHECK(wb == 13); + CHECK(we == 20); + + // Backward direction, window_begin < 1 + thread.computeWindowBounds(/*playhead=*/3, /*dir=*/-1, /*ahead_count=*/10, /*timeline_end=*/100, wb, we); + CHECK(wb == 1); // clamped + CHECK(we == 3); +} + +TEST_CASE("clearCacheIfPaused: clears only when paused and not in cache", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + CacheMemory cache(/*max_bytes=*/100000000); + + // Create a Timeline so that clearCacheIfPaused can call ClearAllCache safely + Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(24,1), + /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO); + timeline.SetCache(&cache); + thread.Reader(&timeline); + + // Add a frame so Contains returns true for 5 and 10 + cache.Add(std::make_shared(5, 0, 0)); + cache.Add(std::make_shared(10, 0, 0)); + + // Paused, playhead not in cache → should clear all cache + bool didClear = thread.clearCacheIfPaused(/*playhead=*/42, /*paused=*/true, &cache); + CHECK(didClear); + CHECK(cache.Count() == 0); + + // Re-add a frame for next checks + cache.Add(std::make_shared(5, 0, 0)); + + // Paused, but playhead IS in cache → no clear + didClear = thread.clearCacheIfPaused(/*playhead=*/5, /*paused=*/true, &cache); + CHECK(!didClear); + CHECK(cache.Contains(5)); + + // Not paused → should not clear even if playhead missing + didClear = thread.clearCacheIfPaused(/*playhead=*/99, /*paused=*/false, &cache); + CHECK(!didClear); + CHECK(cache.Contains(5)); +} + +TEST_CASE("handleUserSeek: sets last_cached_index to playhead - dir", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + + thread.setLastCachedIndex(100); + thread.handleUserSeek(/*playhead=*/50, /*dir=*/1); + CHECK(thread.getLastCachedIndex() == 49); + + thread.handleUserSeek(/*playhead=*/50, /*dir=*/-1); + CHECK(thread.getLastCachedIndex() == 51); +} + +TEST_CASE("prefetchWindow: forward caching with FFmpegReader & CacheMemory", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + CacheMemory cache(/*max_bytes=*/100000000); + + // Use a real test file via FFmpegReader + std::string path = std::string(TEST_MEDIA_PATH) + "sintel_trailer-720p.mp4"; + FFmpegReader reader(path); + reader.Open(); + + // Setup: window [1..5], dir=1, last_cached_index=0 + thread.setLastCachedIndex(0); + int64_t window_begin = 1, window_end = 5; + + bool wasFull = thread.prefetchWindow(&cache, window_begin, window_end, /*dir=*/1, &reader); + CHECK(!wasFull); + + // Should have cached frames 1..5 + CHECK(thread.getLastCachedIndex() == window_end); + for (int64_t f = 1; f <= 5; ++f) { + CHECK(cache.Contains(f)); + } + + // Now window is full; next prefetch should return true + wasFull = thread.prefetchWindow(&cache, window_begin, window_end, /*dir=*/1, &reader); + CHECK(wasFull); + CHECK(thread.getLastCachedIndex() == window_end); +} + +TEST_CASE("prefetchWindow: backward caching with FFmpegReader & CacheMemory", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + CacheMemory cache(/*max_bytes=*/100000000); + + // Use a real test file via FFmpegReader + std::string path = std::string(TEST_MEDIA_PATH) + "sintel_trailer-720p.mp4"; + FFmpegReader reader(path); + reader.Open(); + + // Setup: window [10..15], dir=-1, last_cached_index=16 + thread.setLastCachedIndex(16); + int64_t window_begin = 10, window_end = 15; + + bool wasFull = thread.prefetchWindow(&cache, window_begin, window_end, /*dir=*/-1, &reader); + CHECK(!wasFull); + + // Should have cached frames 15..10 + CHECK(thread.getLastCachedIndex() == window_begin); + for (int64_t f = 10; f <= 15; ++f) { + CHECK(cache.Contains(f)); + } + + // Next call should return true + wasFull = thread.prefetchWindow(&cache, window_begin, window_end, /*dir=*/-1, &reader); + CHECK(wasFull); + CHECK(thread.getLastCachedIndex() == window_begin); +} + +TEST_CASE("prefetchWindow: interrupt on userSeeked flag", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + CacheMemory cache(/*max_bytes=*/100000000); + + // Use a real test file via FFmpegReader + std::string path = std::string(TEST_MEDIA_PATH) + "sintel_trailer-720p.mp4"; + FFmpegReader reader(path); + reader.Open(); + + // Window [20..30], dir=1, last_cached_index=19 + thread.setLastCachedIndex(19); + int64_t window_begin = 20, window_end = 30; + + // Subclass CacheMemory to interrupt on frame 23 + class InterruptingCache : public CacheMemory { + public: + TestableVideoCacheThread* tcb; + InterruptingCache(int64_t maxb, TestableVideoCacheThread* t) + : CacheMemory(maxb), tcb(t) {} + void Add(std::shared_ptr frame) override { + int64_t idx = frame->number; // use public member 'number' + CacheMemory::Add(frame); + if (idx == 23) { + tcb->forceUserSeekFlag(); + } + } + } interruptingCache(/*max_bytes=*/100000000, &thread); + + bool wasFull = thread.prefetchWindow(&interruptingCache, + window_begin, + window_end, + /*dir=*/1, + &reader); + + // Should stop at 23 + CHECK(thread.getLastCachedIndex() == 23); + CHECK(!wasFull); +}