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 diff --git a/src/Qt/VideoCacheThread.cpp b/src/Qt/VideoCacheThread.cpp index 35dc9f8c..4697ffe8 100644 --- a/src/Qt/VideoCacheThread.cpp +++ b/src/Qt/VideoCacheThread.cpp @@ -6,265 +6,311 @@ * @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 #include -#include // for std::this_thread::sleep_for -#include // for std::chrono::microseconds 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) + , reader(nullptr) + , force_directional_cache(false) + , last_cached_index(0) { } // 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) + // Play the video + void VideoCacheThread::Play() { - // 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); + is_playing = true; } - // 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) { + // Stop the video + void VideoCacheThread::Stop() + { + is_playing = false; + } + + // Is cache ready for playback (pre-roll) + bool VideoCacheThread::isReady() + { + return (cached_frame_count > min_frames_ahead); + } + + void VideoCacheThread::setSpeed(int new_speed) + { + // Only update last_speed and last_dir when new_speed != 0 if (new_speed != 0) { - // Track last non-zero speed 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) + 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); - - // approximate audio size (sample rate / 24 fps) - total_bytes += ((sample_rate * channels) / fps) * sizeof(float); - - // return size of this frame - return total_bytes; + // 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; } - // Play the video - void VideoCacheThread::Play() { - // Start playing - is_playing = true; - } - - // Stop the audio - void VideoCacheThread::Stop() { - // Stop playing - is_playing = false; - } - - // Is cache ready for playback (pre-roll) - bool VideoCacheThread::isReady() { - return (cached_frame_count > min_frames_ahead); - } - - // Start the thread - void VideoCacheThread::run() + void VideoCacheThread::Seek(int64_t new_position, bool start_preroll) { - // Types for storing time durations in whole and fractional microseconds - using micro_sec = std::chrono::microseconds; - using double_micro_sec = std::chrono::duration; + if (start_preroll) { + userSeeked = true; + } + requested_display_frame = new_position; + } - while (!threadShouldExit() && is_playing) { - // Get settings - Settings *s = Settings::Instance(); + void VideoCacheThread::Seek(int64_t new_position) + { + Seek(new_position, false); + } - // init local vars - min_frames_ahead = s->VIDEO_CACHE_MIN_PREROLL_FRAMES; - max_frames_ahead = s->VIDEO_CACHE_MAX_PREROLL_FRAMES; + 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); + } - // 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; + void VideoCacheThread::handleUserSeek(int64_t playhead, int dir) + { + // Place last_cached_index just “behind” playhead in the given dir + last_cached_index = playhead - dir; + } - // 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; + 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; } - // 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); - 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; - - // 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()); + if (!cache->Contains(next_frame)) { + // Frame missing, fetch and add + try { + auto framePtr = reader->GetFrame(next_frame); + cache->Add(framePtr); + ++cached_frame_count; } - - // 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; - } - } - - // 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; - } - - // 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; - } - 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; - } - - // Reset cache break-loop flag - should_break = false; - - // 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); - } - catch (const OutOfBoundsFrame & e) { } - } - - // Check if thread has stopped OR should_break is triggered - if (!is_playing || should_break || !s->ENABLE_PLAYBACK_CACHING) { - should_break = false; + catch (const OutOfBoundsFrame&) { break; } - + window_full = false; + } + else { + cache->Touch(next_frame); } - // Sleep for a fraction of frame duration - std::this_thread::sleep_for(frame_duration / 2); - } + last_cached_index = next_frame; + next_frame += dir; + } - return; + 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 63f40c36..fc2c944f 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 @@ -17,72 +17,163 @@ #include #include +#include namespace openshot { using juce::Thread; - using juce::WaitableEvent; /** - * @brief The video cache class. + * @brief Handles prefetching and caching of video/audio frames for smooth playback. + * + * 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 : 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 member variables and assumes forward direction on first launch. + VideoCacheThread(); + ~VideoCacheThread() override; + + /// @return True if at least min_frames_ahead frames have been cached. bool isReady(); + + /// Set is_playing = true, so run() will begin caching/playback. + void Play(); + + /// Set is_playing = false, effectively pausing playback (caching still runs). + void Stop(); + + /** + * @brief Set playback speed/direction. Positive = forward, negative = rewind, zero = pause. + * @param new_speed + * + * 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); + + /// @return The current speed (1=normal, 2=fast, –1=rewind, etc.) + int getSpeed() const { return speed; } + + /// Seek to a specific frame (no preroll). + void Seek(int64_t 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 Attach a ReaderBase (e.g. Timeline, FFmpegReader) and begin caching. + * @param new_reader + */ + void Reader(ReaderBase* new_reader) { reader = new_reader; Play(); } + + protected: + /// Thread entry point: loops until threadShouldExit() is true. + void run() override; + + /** + * @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); + + //---------- 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 (for tracking). + int last_dir; ///< Last direction sign (+1 forward, –1 backward). + + 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 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 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. + + ReaderBase* reader; ///< The source reader (e.g., Timeline, FFmpegReader). + bool force_directional_cache; ///< (Reserved for future use). + + int64_t last_cached_index; ///< Index of the most recently cached frame. }; -} + +} // namespace openshot #endif // OPENSHOT_VIDEO_CACHE_THREAD_H 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); +}