Files
libopenshot/src/Qt/VideoCacheThread.cpp
Jonathan Thomas 4387f8b394 - Timeline composition no longer mutates cached clip frames (fixes trails/ghosting).
- Clip updates via ApplyJsonDiff now also clear clip cache (not just timeline/reader ranges).
- Added regressions for cache invalidation + no cached-frame mutation; kept paused preroll/cache-bar behavior intact.
2026-03-01 14:19:27 -06:00

562 lines
20 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file
* @brief Source file for VideoCacheThread class
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// 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 "Settings.h"
#include "Timeline.h"
#include <thread>
#include <chrono>
#include <algorithm>
namespace openshot
{
// Constructor
VideoCacheThread::VideoCacheThread()
: Thread("video-cache")
, speed(0)
, last_speed(1)
, 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)
, min_frames_ahead(4)
, timeline_max_frame(0)
, reader(nullptr)
, force_directional_cache(false)
, last_cached_index(0)
{
}
// Destructor
VideoCacheThread::~VideoCacheThread()
{
}
// Is cache ready for playback (pre-roll)
bool VideoCacheThread::isReady()
{
if (!reader) {
return false;
}
const int64_t ready_min = min_frames_ahead.load();
if (ready_min < 0) {
return true;
}
const int64_t cached_index = last_cached_index.load();
const int64_t playhead = requested_display_frame.load();
int dir = computeDirection();
// Near timeline boundaries, don't require more pre-roll than can exist.
int64_t max_frame = reader->info.video_length;
if (auto* timeline = dynamic_cast<Timeline*>(reader)) {
const int64_t timeline_max = timeline->GetMaxFrame();
if (timeline_max > 0) {
max_frame = timeline_max;
}
}
if (max_frame < 1) {
return false;
}
int64_t required_ahead = ready_min;
int64_t available_ahead = (dir > 0)
? std::max<int64_t>(0, max_frame - playhead)
: std::max<int64_t>(0, playhead - 1);
required_ahead = std::min(required_ahead, available_ahead);
if (dir > 0) {
return (cached_index >= playhead + required_ahead);
}
return (cached_index <= playhead - required_ahead);
}
void VideoCacheThread::setSpeed(int new_speed)
{
// Only update last_speed and last_dir when new_speed != 0
if (new_speed != 0) {
last_speed.store(new_speed);
last_dir.store(new_speed > 0 ? 1 : -1);
// Leaving paused/scrub context: resume normal cache behavior.
scrub_active.store(false);
}
speed.store(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<int64_t>(width) * height * sizeof(char) * 4;
// Approximate audio: (sample_rate * channels)/fps samples per frame
bytes += ((sample_rate * channels) / fps) * sizeof(float);
return bytes;
}
/// Start the cache thread at high priority, and return true if its actually running.
bool VideoCacheThread::StartThread()
{
// JUCEs startThread() returns void, so we launch it and then check if
// the thread actually started:
startThread(Priority::high);
return isThreadRunning();
}
/// Stop the cache thread, waiting up to timeoutMs ms. Returns true if it actually stopped.
bool VideoCacheThread::StopThread(int timeoutMs)
{
stopThread(timeoutMs);
return !isThreadRunning();
}
void VideoCacheThread::Seek(int64_t new_position, bool start_preroll)
{
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;
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) {
if (cache_contains) {
cache->Remove(new_position);
}
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);
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)
{
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
{
// If speed ≠ 0, use its sign; if speed==0, keep last_dir
const int current_speed = speed.load();
if (current_speed != 0) {
return (current_speed > 0 ? 1 : -1);
}
return last_dir.load();
}
void VideoCacheThread::handleUserSeek(int64_t playhead, int dir)
{
// Place last_cached_index just “behind” playhead in the given dir
last_cached_index.store(playhead - dir);
}
void VideoCacheThread::handleUserSeekWithPreroll(int64_t playhead,
int dir,
int64_t timeline_end,
int64_t preroll_frames)
{
int64_t preroll_start = playhead;
if (preroll_frames > 0) {
if (dir > 0) {
preroll_start = std::max<int64_t>(1, playhead - preroll_frames);
}
else {
preroll_start = std::min<int64_t>(timeline_end, playhead + preroll_frames);
}
}
last_cached_index.store(preroll_start - dir);
}
int64_t VideoCacheThread::computePrerollFrames(const Settings* settings) const
{
if (!settings) {
return 0;
}
int64_t min_frames = settings->VIDEO_CACHE_MIN_PREROLL_FRAMES;
int64_t max_frames = settings->VIDEO_CACHE_MAX_PREROLL_FRAMES;
if (min_frames < 0) {
return 0;
}
if (max_frames > 0 && min_frames > max_frames) {
min_frames = max_frames;
}
return min_frames;
}
bool VideoCacheThread::clearCacheIfPaused(int64_t playhead,
bool paused,
CacheBase* cache)
{
if (paused && !cache->Contains(playhead)) {
// If paused and playhead not in cache, clear everything
if (Timeline* timeline = dynamic_cast<Timeline*>(reader)) {
timeline->ClearAllCache();
}
cached_frame_count.store(0);
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<int64_t>(window_begin, 1);
window_end = std::min<int64_t>(window_end, timeline_end);
}
bool VideoCacheThread::prefetchWindow(CacheBase* cache,
int64_t window_begin,
int64_t window_end,
int dir,
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) ||
(dir < 0 && next_frame >= window_begin))
{
if (threadShouldExit()) {
break;
}
// If a Seek was requested mid-caching, bail out immediately
if (userSeeked.load()) {
break;
}
if (!cache->Contains(next_frame)) {
// Frame missing, fetch and add
try {
auto framePtr = reader->GetFrame(next_frame);
cache->Add(framePtr);
cached_frame_count.store(cache->Count());
++fetched_this_pass;
}
catch (const OutOfBoundsFrame&) {
break;
}
window_full = false;
}
else {
cache->Touch(next_frame);
}
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;
}
void VideoCacheThread::run()
{
using micro_sec = std::chrono::microseconds;
using double_micro_sec = std::chrono::duration<double, micro_sec::period>;
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) {
cached_frame_count.store(cache ? cache->Count() : 0);
min_frames_ahead.store(-1);
std::this_thread::sleep_for(double_micro_sec(50000));
continue;
}
// init local vars
min_frames_ahead.store(settings->VIDEO_CACHE_MIN_PREROLL_FRAMES);
if (!timeline) {
std::this_thread::sleep_for(double_micro_sec(50000));
continue;
}
int64_t timeline_end = timeline->GetMaxFrame();
int64_t playhead = requested_display_frame.load();
bool paused = (speed.load() == 0);
int64_t preroll_frames = computePrerollFrames(settings);
cached_frame_count.store(cache->Count());
// Compute effective direction (±1)
int dir = computeDirection();
if (speed.load() != 0) {
last_dir.store(dir);
}
// Compute bytes_per_frame, max_bytes, and capacity once
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();
int64_t capacity = 0;
if (max_bytes > 0 && bytes_per_frame > 0) {
capacity = max_bytes / bytes_per_frame;
if (capacity > settings->VIDEO_CACHE_MAX_FRAMES) {
capacity = settings->VIDEO_CACHE_MAX_FRAMES;
}
}
// Handle a user-initiated seek
bool did_user_seek = false;
bool use_preroll = false;
{
std::lock_guard<std::mutex> guard(seek_state_mutex);
playhead = requested_display_frame.load();
did_user_seek = userSeeked.load();
use_preroll = preroll_on_next_fill.load();
if (did_user_seek) {
userSeeked.store(false);
preroll_on_next_fill.store(false);
}
}
if (did_user_seek) {
// 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 {
handleUserSeek(playhead, dir);
}
}
else if (!paused && capacity >= 1) {
// In playback mode, check if last_cached_index drifted outside the new window
int64_t base_ahead = static_cast<int64_t>(capacity * settings->VIDEO_CACHE_PERCENT_AHEAD);
int64_t window_begin, window_end;
computeWindowBounds(
playhead,
dir,
base_ahead,
timeline_end,
window_begin,
window_end
);
bool outside_window =
(dir > 0 && last_cached_index.load() > window_end) ||
(dir < 0 && last_cached_index.load() < window_begin);
if (outside_window) {
handleUserSeek(playhead, dir);
}
}
// 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));
continue;
}
int64_t ahead_count = static_cast<int64_t>(capacity *
settings->VIDEO_CACHE_PERCENT_AHEAD);
int64_t window_size = ahead_count + 1;
if (window_size < 1) {
window_size = 1;
}
int64_t ready_target = window_size - 1;
if (ready_target < 0) {
ready_target = 0;
}
int64_t configured_min = settings->VIDEO_CACHE_MIN_PREROLL_FRAMES;
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);
if (did_clear) {
handleUserSeekWithPreroll(playhead, dir, timeline_end, preroll_frames);
}
// 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
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) {
cache->Touch(playhead);
}
// Sleep a short fraction of a frame interval
int64_t sleep_us = static_cast<int64_t>(
1000000.0 / reader->info.fps.ToFloat() / 4.0
);
std::this_thread::sleep_for(double_micro_sec(sleep_us));
}
}
} // namespace openshot