- Playback updates no longer act like seeks.

- Scrubbing inside cached area keeps cache.
- Scrubbing outside cached area clears cache immediately.
- Preroll now starts on seek commit (release), not during drag.
- Temporary debug logs were removed.
- Tests were added for these behaviors.
This commit is contained in:
Jonathan Thomas
2026-02-26 13:22:06 -06:00
parent 1ee89c10f6
commit ff22d416da
6 changed files with 254 additions and 25 deletions

View File

@@ -30,6 +30,8 @@ namespace openshot
, last_dir(1) // assume forward (+1) on first launch
, userSeeked(false)
, preroll_on_next_fill(false)
, clear_cache_on_next_fill(false)
, scrub_active(false)
, requested_display_frame(1)
, current_display_frame(1)
, cached_frame_count(0)
@@ -131,40 +133,90 @@ namespace openshot
bool should_mark_seek = false;
bool should_preroll = false;
int64_t new_cached_count = cached_frame_count.load();
bool entering_scrub = false;
bool leaving_scrub = false;
bool cache_contains = false;
bool should_clear_cache = false;
CacheBase* cache = reader ? reader->GetCache() : nullptr;
if (cache) {
cache_contains = cache->Contains(new_position);
}
if (start_preroll) {
should_mark_seek = true;
CacheBase* cache = reader ? reader->GetCache() : nullptr;
if (cache && !cache->Contains(new_position))
{
// If user initiated seek, and current frame not found (
if (Timeline* timeline = dynamic_cast<Timeline*>(reader)) {
timeline->ClearAllCache();
}
if (cache && !cache_contains) {
// Uncached commit seek: avoid blocking this call path with a
// synchronous ClearAllCache(). The cache thread will reconcile
// window contents on the next iteration around the new playhead.
new_cached_count = 0;
should_preroll = true;
should_clear_cache = true;
}
else if (cache)
{
new_cached_count = cache->Count();
}
leaving_scrub = true;
}
else {
// Scrub preview: keep preroll disabled and interrupt current fill.
// Do not synchronously clear cache here, as that can block seeks.
should_mark_seek = true;
if (cache && !cache_contains) {
new_cached_count = 0;
should_clear_cache = true;
}
else if (cache) {
new_cached_count = cache->Count();
}
entering_scrub = true;
}
{
std::lock_guard<std::mutex> guard(seek_state_mutex);
// Invalidate ready-state baseline immediately on seek so isReady()
// cannot pass/fail against stale cached_index from a prior window.
const int dir = computeDirection();
last_cached_index.store(new_position - dir);
requested_display_frame.store(new_position);
cached_frame_count.store(new_cached_count);
if (start_preroll) {
preroll_on_next_fill.store(should_preroll);
userSeeked.store(should_mark_seek);
preroll_on_next_fill.store(should_preroll);
// Clear behavior follows the latest seek intent.
clear_cache_on_next_fill.store(should_clear_cache);
userSeeked.store(should_mark_seek);
if (entering_scrub) {
scrub_active.store(true);
}
if (leaving_scrub) {
scrub_active.store(false);
}
}
}
void VideoCacheThread::Seek(int64_t new_position)
{
Seek(new_position, false);
NotifyPlaybackPosition(new_position);
}
void VideoCacheThread::NotifyPlaybackPosition(int64_t new_position)
{
if (new_position <= 0) {
return;
}
if (scrub_active.load()) {
return;
}
int64_t new_cached_count = cached_frame_count.load();
if (CacheBase* cache = reader ? reader->GetCache() : nullptr) {
new_cached_count = cache->Count();
}
{
std::lock_guard<std::mutex> guard(seek_state_mutex);
requested_display_frame.store(new_position);
cached_frame_count.store(new_cached_count);
}
}
int VideoCacheThread::computeDirection() const
@@ -257,10 +309,12 @@ namespace openshot
int64_t window_begin,
int64_t window_end,
int dir,
ReaderBase* reader)
ReaderBase* reader,
int64_t max_frames_to_fetch)
{
bool window_full = true;
int64_t next_frame = last_cached_index.load() + dir;
int64_t fetched_this_pass = 0;
// Advance from last_cached_index toward window boundary
while ((dir > 0 && next_frame <= window_end) ||
@@ -280,6 +334,7 @@ namespace openshot
auto framePtr = reader->GetFrame(next_frame);
cache->Add(framePtr);
cached_frame_count.store(cache->Count());
++fetched_this_pass;
}
catch (const OutOfBoundsFrame&) {
break;
@@ -292,6 +347,12 @@ namespace openshot
last_cached_index.store(next_frame);
next_frame += dir;
// In active playback, avoid long uninterrupted prefetch bursts
// that can delay player thread frame retrieval.
if (max_frames_to_fetch > 0 && fetched_this_pass >= max_frames_to_fetch) {
break;
}
}
return window_full;
@@ -305,6 +366,21 @@ namespace openshot
while (!threadShouldExit()) {
Settings* settings = Settings::Instance();
CacheBase* cache = reader ? reader->GetCache() : nullptr;
Timeline* timeline = dynamic_cast<Timeline*>(reader);
// Process deferred clears even when caching is currently disabled
// (e.g. active scrub mode), so stale ranges are removed promptly.
bool should_clear_cache = clear_cache_on_next_fill.exchange(false);
if (should_clear_cache && timeline) {
const int dir_on_clear = computeDirection();
const int64_t clear_playhead = requested_display_frame.load();
timeline->ClearAllCache();
cached_frame_count.store(0);
// Reset ready baseline immediately after clear. Otherwise a
// stale last_cached_index from the old cache window can make
// isReady() report true before new preroll is actually filled.
last_cached_index.store(clear_playhead - dir_on_clear);
}
// If caching disabled or no reader, mark cache as ready and sleep briefly
if (!settings->ENABLE_PLAYBACK_CACHING || !cache) {
@@ -317,7 +393,6 @@ namespace openshot
// init local vars
min_frames_ahead.store(settings->VIDEO_CACHE_MIN_PREROLL_FRAMES);
Timeline* timeline = dynamic_cast<Timeline*>(reader);
if (!timeline) {
std::this_thread::sleep_for(double_micro_sec(50000));
continue;
@@ -366,7 +441,10 @@ namespace openshot
}
}
if (did_user_seek) {
if (use_preroll) {
// During active playback, prioritize immediate forward readiness
// from the playhead. Use directional preroll offset only while
// paused/scrubbing contexts.
if (use_preroll && paused) {
handleUserSeekWithPreroll(playhead, dir, timeline_end, preroll_frames);
}
else {
@@ -395,6 +473,22 @@ namespace openshot
}
}
// If a clear was requested by a seek that arrived after the loop
// began, apply it now before any additional prefetch work. This
// avoids "build then suddenly clear" behavior during playback.
bool should_clear_mid_loop = clear_cache_on_next_fill.exchange(false);
if (should_clear_mid_loop && timeline) {
timeline->ClearAllCache();
cached_frame_count.store(0);
last_cached_index.store(playhead - dir);
}
// While user is dragging/scrubbing, skip cache prefetch work.
if (scrub_active.load()) {
std::this_thread::sleep_for(double_micro_sec(10000));
continue;
}
// If capacity is insufficient, sleep and retry
if (capacity < 1) {
std::this_thread::sleep_for(double_micro_sec(50000));
@@ -411,7 +505,8 @@ namespace openshot
ready_target = 0;
}
int64_t configured_min = settings->VIDEO_CACHE_MIN_PREROLL_FRAMES;
min_frames_ahead.store(std::min<int64_t>(configured_min, ready_target));
const int64_t required_ahead = std::min<int64_t>(configured_min, ready_target);
min_frames_ahead.store(required_ahead);
// If paused and playhead is no longer in cache, clear everything
bool did_clear = clearCacheIfPaused(playhead, paused, cache);
@@ -429,7 +524,21 @@ namespace openshot
window_end);
// Attempt to fill any missing frames in that window
bool window_full = prefetchWindow(cache, window_begin, window_end, dir, reader);
int64_t max_frames_to_fetch = -1;
if (!paused) {
// Keep cache thread responsive during playback seeks so player
// can start as soon as pre-roll is met instead of waiting for a
// full cache window pass.
max_frames_to_fetch = 8;
}
bool window_full = prefetchWindow(
cache,
window_begin,
window_end,
dir,
reader,
max_frames_to_fetch
);
// If paused and window was already full, keep playhead fresh
if (paused && window_full) {