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);
+}