diff --git a/examples/Example.cpp b/examples/Example.cpp index 2067fb56..0e143c3b 100644 --- a/examples/Example.cpp +++ b/examples/Example.cpp @@ -13,83 +13,116 @@ #include #include #include +#include +#include "Clip.h" #include "Frame.h" #include "FFmpegReader.h" -#include "FFmpegWriter.h" +#include "Settings.h" #include "Timeline.h" -#include "Qt/VideoCacheThread.h" // <— your new header using namespace openshot; int main(int argc, char* argv[]) { + using clock = std::chrono::high_resolution_clock; + auto total_start = clock::now(); + const std::string output_dir = "/home/jonathan/Downloads"; + const std::string input_paths[] = { + "/home/jonathan/Videos/3.4 Release/Screencasts/Timing.mp4", + "/home/jonathan/Downloads/openshot-testing/sintel_trailer-720p.mp4" + }; + const int64_t frames_to_fetch[] = {175, 225, 240, 500, 1000}; + const bool use_hw_decode = false; - // 1) Open the FFmpegReader as usual - const char* input_path = "/home/jonathan/Downloads/openshot-testing/sintel_trailer-720p.mp4"; - FFmpegReader reader(input_path); - reader.Open(); + std::cout << "Hardware decode: " << (use_hw_decode ? "ON" : "OFF") << "\n"; + openshot::Settings::Instance()->HARDWARE_DECODER = use_hw_decode ? 1 : 0; - const int64_t total_frames = reader.info.video_length; - std::cout << "Total frames: " << total_frames << "\n"; + for (const std::string& input_path : input_paths) { + auto file_start = clock::now(); + std::string base = input_path; + size_t slash = base.find_last_of('/'); + if (slash != std::string::npos) { + base = base.substr(slash + 1); + } + std::cout << "\n=== File: " << base << " ===\n"; + auto t0 = clock::now(); + FFmpegReader reader(input_path.c_str()); + auto t1 = clock::now(); + std::cout << "FFmpegReader ctor: " + << std::chrono::duration_cast(t1 - t0).count() + << " ms\n"; - Timeline timeline(reader.info.width, reader.info.height, reader.info.fps, reader.info.sample_rate, reader.info.channels, reader.info.channel_layout); - Clip c1(&reader); - timeline.AddClip(&c1); - timeline.Open(); - timeline.DisplayInfo(); + auto t2 = clock::now(); + reader.Open(); + auto t3 = clock::now(); + std::cout << "FFmpegReader Open(): " + << std::chrono::duration_cast(t3 - t2).count() + << " ms\n"; + auto t4 = clock::now(); + Timeline timeline(1920, 1080, Fraction(30, 1), reader.info.sample_rate, reader.info.channels, reader.info.channel_layout); + timeline.SetMaxSize(640, 480); + auto t5 = clock::now(); + std::cout << "Timeline ctor (1080p30): " + << std::chrono::duration_cast(t5 - t4).count() + << " ms\n"; - // 2) Construct a VideoCacheThread around 'reader' and start its background loop - // (VideoCacheThread inherits juce::Thread) - std::shared_ptr cache = std::make_shared(); - cache->Reader(&timeline); // attaches the FFmpegReader and internally calls Play() - cache->StartThread(); // juce::Thread method, begins run() + auto t6 = clock::now(); + Clip c1(&reader); + auto t7 = clock::now(); + std::cout << "Clip ctor: " + << std::chrono::duration_cast(t7 - t6).count() + << " ms\n"; - // 3) Set up the writer exactly as before - FFmpegWriter writer("/home/jonathan/Downloads/performance‐cachetest.mp4"); - writer.SetAudioOptions("aac", 48000, 192000); - writer.SetVideoOptions("libx264", 1280, 720, Fraction(30, 1), 5000000); - writer.Open(); + timeline.AddClip(&c1); - // 4) Forward pass: for each frame 1…N, tell the cache thread to seek to that frame, - // then immediately call cache->GetFrame(frame), which will block only if that frame - // hasn’t been decoded into the cache yet. - auto t0 = std::chrono::high_resolution_clock::now(); - cache->setSpeed(1); - for (int64_t f = 1; f <= total_frames; ++f) { - float pct = (float(f) / total_frames) * 100.0f; - std::cout << "Forward: requesting frame " << f << " (" << pct << "%)\n"; + auto t8 = clock::now(); + timeline.Open(); + auto t9 = clock::now(); + std::cout << "Timeline Open(): " + << std::chrono::duration_cast(t9 - t8).count() + << " ms\n"; - cache->Seek(f); // signal “I need frame f now (and please prefetch f+1, f+2, …)” - std::shared_ptr framePtr = timeline.GetFrame(f); - writer.WriteFrame(framePtr); + for (int64_t frame_number : frames_to_fetch) { + auto loop_start = clock::now(); + std::cout << "Requesting frame " << frame_number << "...\n"; + + auto t10 = clock::now(); + std::shared_ptr frame = timeline.GetFrame(frame_number); + auto t11 = clock::now(); + std::cout << "Timeline GetFrame(" << frame_number << "): " + << std::chrono::duration_cast(t11 - t10).count() + << " ms\n"; + + std::string out_path = output_dir + "/frame-" + base + "-" + std::to_string(frame_number) + ".jpg"; + + auto t12 = clock::now(); + frame->Thumbnail(out_path, 200, 80, "", "", "#000000", false, "JPEG", 95, 0.0f); + auto t13 = clock::now(); + std::cout << "Frame Thumbnail() JPEG (" << frame_number << "): " + << std::chrono::duration_cast(t13 - t12).count() + << " ms\n"; + + auto loop_end = clock::now(); + std::cout << "Frame loop total (" << frame_number << "): " + << std::chrono::duration_cast(loop_end - loop_start).count() + << " ms\n"; + } + + reader.Close(); + timeline.Close(); + + auto file_end = clock::now(); + std::cout << "File total (" << base << "): " + << std::chrono::duration_cast(file_end - file_start).count() + << " ms\n"; } - auto t1 = std::chrono::high_resolution_clock::now(); - auto forward_ms = std::chrono::duration_cast(t1 - t0).count(); - // 5) Backward pass: same idea in reverse - auto t2 = std::chrono::high_resolution_clock::now(); - cache->setSpeed(-1); - for (int64_t f = total_frames; f >= 1; --f) { - float pct = (float(total_frames - f + 1) / total_frames) * 100.0f; - std::cout << "Backward: requesting frame " << f << " (" << pct << "%)\n"; - - cache->Seek(f); - std::shared_ptr framePtr = timeline.GetFrame(f); - writer.WriteFrame(framePtr); - } - auto t3 = std::chrono::high_resolution_clock::now(); - auto backward_ms = std::chrono::duration_cast(t3 - t2).count(); - - std::cout << "\nForward pass elapsed: " << forward_ms << " ms\n"; - std::cout << "Backward pass elapsed: " << backward_ms << " ms\n"; - - // 6) Shut down the cache thread, close everything - cache->StopThread(10000); // politely tells run() to exit, waits up to 10s - reader.Close(); - writer.Close(); - timeline.Close(); + auto total_end = clock::now(); + std::cout << "Total elapsed: " + << std::chrono::duration_cast(total_end - total_start).count() + << " ms\n"; return 0; } diff --git a/src/AudioReaderSource.cpp b/src/AudioReaderSource.cpp index 2c50e873..d08487de 100644 --- a/src/AudioReaderSource.cpp +++ b/src/AudioReaderSource.cpp @@ -42,6 +42,8 @@ void AudioReaderSource::getNextAudioBlock(const juce::AudioSourceChannelInfo& in } while (remaining_samples > 0) { + const int previous_remaining = remaining_samples; + frame.reset(); try { // Get current frame object if (reader) { @@ -53,9 +55,19 @@ void AudioReaderSource::getNextAudioBlock(const juce::AudioSourceChannelInfo& in // Get audio samples if (reader && frame) { + const int frame_samples = frame->GetAudioSamplesCount(); + const int frame_channels = frame->GetAudioChannelsCount(); + + // Corrupt/unsupported streams can yield frames without audio data. + // Avoid a tight loop that never consumes remaining_samples. + if (frame_samples <= 0 || frame_channels <= 0) { + info.buffer->clear(remaining_position, remaining_samples); + break; + } + if (sample_position + remaining_samples <= frame->GetAudioSamplesCount()) { // Success, we have enough samples - for (int channel = 0; channel < frame->GetAudioChannelsCount(); channel++) { + for (int channel = 0; channel < frame_channels; channel++) { if (channel < info.buffer->getNumChannels()) { info.buffer->addFrom(channel, remaining_position, *frame->GetAudioSampleBuffer(), channel, sample_position, remaining_samples); @@ -68,7 +80,12 @@ void AudioReaderSource::getNextAudioBlock(const juce::AudioSourceChannelInfo& in } else if (sample_position + remaining_samples > frame->GetAudioSamplesCount()) { // Not enough samples, take what we can int amount_to_copy = frame->GetAudioSamplesCount() - sample_position; - for (int channel = 0; channel < frame->GetAudioChannelsCount(); channel++) { + if (amount_to_copy <= 0) { + info.buffer->clear(remaining_position, remaining_samples); + break; + } + + for (int channel = 0; channel < frame_channels; channel++) { if (channel < info.buffer->getNumChannels()) { info.buffer->addFrom(channel, remaining_position, *frame->GetAudioSampleBuffer(), channel, sample_position, amount_to_copy); @@ -84,7 +101,14 @@ void AudioReaderSource::getNextAudioBlock(const juce::AudioSourceChannelInfo& in frame_position += speed; sample_position = 0; // reset for new frame } + } else { + info.buffer->clear(remaining_position, remaining_samples); + break; + } + if (remaining_samples == previous_remaining) { + info.buffer->clear(remaining_position, remaining_samples); + break; } } } diff --git a/src/CacheMemory.cpp b/src/CacheMemory.cpp index bd9a6c2c..a89c9f7e 100644 --- a/src/CacheMemory.cpp +++ b/src/CacheMemory.cpp @@ -19,7 +19,7 @@ using namespace std; using namespace openshot; // Default constructor, no max bytes -CacheMemory::CacheMemory() : CacheBase(0), bytes_freed_since_trim(0) { +CacheMemory::CacheMemory() : CacheBase(0) { // Set cache type name cache_type = "CacheMemory"; range_version = 0; @@ -27,7 +27,7 @@ CacheMemory::CacheMemory() : CacheBase(0), bytes_freed_since_trim(0) { } // Constructor that sets the max bytes to cache -CacheMemory::CacheMemory(int64_t max_bytes) : CacheBase(max_bytes), bytes_freed_since_trim(0) { +CacheMemory::CacheMemory(int64_t max_bytes) : CacheBase(max_bytes) { // Set cache type name cache_type = "CacheMemory"; range_version = 0; @@ -70,6 +70,9 @@ void CacheMemory::Add(std::shared_ptr frame) // Check if frame is already contained in cache bool CacheMemory::Contains(int64_t frame_number) { + // Create a scoped lock, to protect the cache from multiple threads + const std::lock_guard lock(*cacheMutex); + if (frames.count(frame_number) > 0) { return true; } else { @@ -162,8 +165,6 @@ void CacheMemory::Remove(int64_t start_frame_number, int64_t end_frame_number) { // Create a scoped lock, to protect the cache from multiple threads const std::lock_guard lock(*cacheMutex); - int64_t removed_bytes = 0; - // Loop through frame numbers std::deque::iterator itr; for(itr = frame_numbers.begin(); itr != frame_numbers.end();) @@ -182,10 +183,6 @@ void CacheMemory::Remove(int64_t start_frame_number, int64_t end_frame_number) { if (*itr_ordered >= start_frame_number && *itr_ordered <= end_frame_number) { - // Count bytes freed before erasing the frame - if (frames.count(*itr_ordered)) - removed_bytes += frames[*itr_ordered]->GetBytes(); - // erase frame number frames.erase(*itr_ordered); itr_ordered = ordered_frame_numbers.erase(itr_ordered); @@ -193,17 +190,6 @@ void CacheMemory::Remove(int64_t start_frame_number, int64_t end_frame_number) itr_ordered++; } - if (removed_bytes > 0) - { - bytes_freed_since_trim += removed_bytes; - if (bytes_freed_since_trim >= TRIM_THRESHOLD_BYTES) - { - // Periodically return freed arenas to the OS - if (TrimMemoryToOS()) - bytes_freed_since_trim = 0; - } - } - // Needs range processing (since cache has changed) needs_range_processing = true; } @@ -246,10 +232,8 @@ void CacheMemory::Clear() ordered_frame_numbers.clear(); ordered_frame_numbers.shrink_to_fit(); needs_range_processing = true; - bytes_freed_since_trim = 0; - - // Trim freed arenas back to OS after large clears - TrimMemoryToOS(true); + // Trim freed arenas back to OS after large clears (debounced) + TrimMemoryToOS(); } // Count the frames in the queue diff --git a/src/CacheMemory.h b/src/CacheMemory.h index 9972b102..e35fdb11 100644 --- a/src/CacheMemory.h +++ b/src/CacheMemory.h @@ -28,10 +28,8 @@ namespace openshot { */ class CacheMemory : public CacheBase { private: - static constexpr int64_t TRIM_THRESHOLD_BYTES = 1024LL * 1024 * 1024; ///< Release memory after freeing this much memory std::map > frames; ///< This map holds the frame number and Frame objects std::deque frame_numbers; ///< This queue holds a sequential list of cached Frame numbers - int64_t bytes_freed_since_trim; ///< Tracks bytes freed to trigger a heap trim /// Clean up cached frames that exceed the max number of bytes void CleanUp(); diff --git a/src/FFmpegReader.cpp b/src/FFmpegReader.cpp index 82c595f3..18a9084c 100644 --- a/src/FFmpegReader.cpp +++ b/src/FFmpegReader.cpp @@ -79,7 +79,9 @@ FFmpegReader::FFmpegReader(const std::string &path, bool inspect_reader) FFmpegReader::FFmpegReader(const std::string &path, DurationStrategy duration_strategy, bool inspect_reader) : last_frame(0), is_seeking(0), seeking_pts(0), seeking_frame(0), seek_count(0), NO_PTS_OFFSET(-99999), path(path), is_video_seek(true), check_interlace(false), check_fps(false), enable_seek(true), is_open(false), - seek_audio_frame_found(0), seek_video_frame_found(0),is_duration_known(false), largest_frame_processed(0), + seek_audio_frame_found(0), seek_video_frame_found(0), + last_seek_max_frame(-1), seek_stagnant_count(0), + is_duration_known(false), largest_frame_processed(0), current_video_frame(0), packet(NULL), duration_strategy(duration_strategy), audio_pts(0), video_pts(0), pFormatCtx(NULL), videoStream(-1), audioStream(-1), pCodecCtx(NULL), aCodecCtx(NULL), pStream(NULL), aStream(NULL), pFrame(NULL), previous_packet_location{-1,0}, @@ -95,8 +97,10 @@ FFmpegReader::FFmpegReader(const std::string &path, DurationStrategy duration_st audio_pts_seconds = NO_PTS_OFFSET; // Init cache - working_cache.SetMaxBytesFromInfo(info.fps.ToDouble() * 2, info.width, info.height, info.sample_rate, info.channels); - final_cache.SetMaxBytesFromInfo(24, info.width, info.height, info.sample_rate, info.channels); + const int init_working_cache_frames = std::max(Settings::Instance()->CACHE_MIN_FRAMES, OPEN_MP_NUM_PROCESSORS * 4); + const int init_final_cache_frames = std::max(Settings::Instance()->CACHE_MIN_FRAMES, OPEN_MP_NUM_PROCESSORS * 4); + working_cache.SetMaxBytesFromInfo(init_working_cache_frames, info.width, info.height, info.sample_rate, info.channels); + final_cache.SetMaxBytesFromInfo(init_final_cache_frames, info.width, info.height, info.sample_rate, info.channels); // Open and Close the reader, to populate its attributes (such as height, width, etc...) if (inspect_reader) { @@ -136,52 +140,69 @@ static enum AVPixelFormat get_hw_dec_format(AVCodecContext *ctx, const enum AVPi { const enum AVPixelFormat *p; + // Prefer only the format matching the selected hardware decoder + int selected = openshot::Settings::Instance()->HARDWARE_DECODER; + for (p = pix_fmts; *p != AV_PIX_FMT_NONE; p++) { switch (*p) { #if defined(__linux__) // Linux pix formats case AV_PIX_FMT_VAAPI: - hw_de_av_pix_fmt_global = AV_PIX_FMT_VAAPI; - hw_de_av_device_type_global = AV_HWDEVICE_TYPE_VAAPI; - return *p; + if (selected == 1) { + hw_de_av_pix_fmt_global = AV_PIX_FMT_VAAPI; + hw_de_av_device_type_global = AV_HWDEVICE_TYPE_VAAPI; + return *p; + } break; case AV_PIX_FMT_VDPAU: - hw_de_av_pix_fmt_global = AV_PIX_FMT_VDPAU; - hw_de_av_device_type_global = AV_HWDEVICE_TYPE_VDPAU; - return *p; + if (selected == 6) { + hw_de_av_pix_fmt_global = AV_PIX_FMT_VDPAU; + hw_de_av_device_type_global = AV_HWDEVICE_TYPE_VDPAU; + return *p; + } break; #endif #if defined(_WIN32) // Windows pix formats case AV_PIX_FMT_DXVA2_VLD: - hw_de_av_pix_fmt_global = AV_PIX_FMT_DXVA2_VLD; - hw_de_av_device_type_global = AV_HWDEVICE_TYPE_DXVA2; - return *p; + if (selected == 3) { + hw_de_av_pix_fmt_global = AV_PIX_FMT_DXVA2_VLD; + hw_de_av_device_type_global = AV_HWDEVICE_TYPE_DXVA2; + return *p; + } break; case AV_PIX_FMT_D3D11: - hw_de_av_pix_fmt_global = AV_PIX_FMT_D3D11; - hw_de_av_device_type_global = AV_HWDEVICE_TYPE_D3D11VA; - return *p; + if (selected == 4) { + hw_de_av_pix_fmt_global = AV_PIX_FMT_D3D11; + hw_de_av_device_type_global = AV_HWDEVICE_TYPE_D3D11VA; + return *p; + } break; #endif #if defined(__APPLE__) // Apple pix formats case AV_PIX_FMT_VIDEOTOOLBOX: - hw_de_av_pix_fmt_global = AV_PIX_FMT_VIDEOTOOLBOX; - hw_de_av_device_type_global = AV_HWDEVICE_TYPE_VIDEOTOOLBOX; - return *p; + if (selected == 5) { + hw_de_av_pix_fmt_global = AV_PIX_FMT_VIDEOTOOLBOX; + hw_de_av_device_type_global = AV_HWDEVICE_TYPE_VIDEOTOOLBOX; + return *p; + } break; #endif // Cross-platform pix formats case AV_PIX_FMT_CUDA: - hw_de_av_pix_fmt_global = AV_PIX_FMT_CUDA; - hw_de_av_device_type_global = AV_HWDEVICE_TYPE_CUDA; - return *p; + if (selected == 2) { + hw_de_av_pix_fmt_global = AV_PIX_FMT_CUDA; + hw_de_av_device_type_global = AV_HWDEVICE_TYPE_CUDA; + return *p; + } break; case AV_PIX_FMT_QSV: - hw_de_av_pix_fmt_global = AV_PIX_FMT_QSV; - hw_de_av_device_type_global = AV_HWDEVICE_TYPE_QSV; - return *p; + if (selected == 7) { + hw_de_av_pix_fmt_global = AV_PIX_FMT_QSV; + hw_de_av_device_type_global = AV_HWDEVICE_TYPE_QSV; + return *p; + } break; default: // This is only here to silence unused-enum warnings @@ -302,7 +323,7 @@ void FFmpegReader::Open() { char *adapter_ptr = NULL; int adapter_num; adapter_num = openshot::Settings::Instance()->HW_DE_DEVICE_SET; - fprintf(stderr, "Hardware decoding device number: %d\n", adapter_num); + ZmqLogger::Instance()->AppendDebugMethod("Hardware decoding device number", "adapter_num", adapter_num); // Set hardware pix format (callback) pCodecCtx->get_format = get_hw_dec_format; @@ -388,6 +409,10 @@ void FFmpegReader::Open() { hw_device_ctx = NULL; // Here the first hardware initialisations are made if (av_hwdevice_ctx_create(&hw_device_ctx, hw_de_av_device_type, adapter_ptr, NULL, 0) >= 0) { + const char* hw_name = av_hwdevice_get_type_name(hw_de_av_device_type); + std::string hw_msg = "HW decode active: "; + hw_msg += (hw_name ? hw_name : "unknown"); + ZmqLogger::Instance()->Log(hw_msg); if (!(pCodecCtx->hw_device_ctx = av_buffer_ref(hw_device_ctx))) { throw InvalidCodec("Hardware device reference create failed.", path); } @@ -420,7 +445,8 @@ void FFmpegReader::Open() { */ } else { - throw InvalidCodec("Hardware device create failed.", path); + ZmqLogger::Instance()->Log("HW decode active: no (falling back to software)"); + throw InvalidCodec("Hardware device create failed.", path); } } #endif // USE_HW_ACCEL @@ -535,23 +561,83 @@ void FFmpegReader::Open() { // Audio encoding does not typically use more than 2 threads (most codecs use 1 thread) aCodecCtx->thread_count = std::min(FF_AUDIO_NUM_PROCESSORS, 2); - if (aCodec == NULL) { - throw InvalidCodec("A valid audio codec could not be found for this file.", path); + bool audio_opened = false; + if (aCodec != NULL) { + // Init options + AVDictionary *opts = NULL; + av_dict_set(&opts, "strict", "experimental", 0); + + // Open audio codec + audio_opened = (avcodec_open2(aCodecCtx, aCodec, &opts) >= 0); + + // Free options + av_dict_free(&opts); } - // Init options - AVDictionary *opts = NULL; - av_dict_set(&opts, "strict", "experimental", 0); + if (audio_opened) { + // Update the File Info struct with audio details (if an audio stream is found) + UpdateAudioInfo(); - // Open audio codec - if (avcodec_open2(aCodecCtx, aCodec, &opts) < 0) - throw InvalidCodec("An audio codec was found, but could not be opened.", path); + // Disable malformed audio stream metadata (prevents divide-by-zero / invalid resampling math) + const bool invalid_audio_info = + (info.channels <= 0) || + (info.sample_rate <= 0) || + (info.audio_timebase.num <= 0) || + (info.audio_timebase.den <= 0) || + (aCodecCtx->sample_fmt == AV_SAMPLE_FMT_NONE); + if (invalid_audio_info) { + ZmqLogger::Instance()->AppendDebugMethod( + "FFmpegReader::Open (Disable invalid audio stream)", + "channels", info.channels, + "sample_rate", info.sample_rate, + "audio_timebase.num", info.audio_timebase.num, + "audio_timebase.den", info.audio_timebase.den, + "sample_fmt", static_cast(aCodecCtx ? aCodecCtx->sample_fmt : AV_SAMPLE_FMT_NONE)); + info.has_audio = false; + info.audio_stream_index = -1; + audioStream = -1; + packet_status.audio_eof = true; + if (aCodecCtx) { + if (avcodec_is_open(aCodecCtx)) { + avcodec_flush_buffers(aCodecCtx); + } + AV_FREE_CONTEXT(aCodecCtx); + aCodecCtx = nullptr; + } + aStream = nullptr; + } + } else { + // Keep decoding video, but disable bad/unsupported audio stream. + ZmqLogger::Instance()->AppendDebugMethod( + "FFmpegReader::Open (Audio codec unavailable; disabling audio)", + "audioStream", audioStream); + info.has_audio = false; + info.audio_stream_index = -1; + audioStream = -1; + packet_status.audio_eof = true; + if (aCodecCtx) { + AV_FREE_CONTEXT(aCodecCtx); + aCodecCtx = nullptr; + } + aStream = nullptr; + } + } - // Free options - av_dict_free(&opts); - - // Update the File Info struct with audio details (if an audio stream is found) - UpdateAudioInfo(); + // Guard invalid frame-rate / timebase values from malformed streams. + if (info.fps.num <= 0 || info.fps.den <= 0) { + ZmqLogger::Instance()->AppendDebugMethod( + "FFmpegReader::Open (Invalid FPS detected; applying fallback)", + "fps.num", info.fps.num, + "fps.den", info.fps.den); + info.fps.num = 30; + info.fps.den = 1; + } + if (info.video_timebase.num <= 0 || info.video_timebase.den <= 0) { + ZmqLogger::Instance()->AppendDebugMethod( + "FFmpegReader::Open (Invalid video_timebase detected; applying fallback)", + "video_timebase.num", info.video_timebase.num, + "video_timebase.den", info.video_timebase.den); + info.video_timebase = info.fps.Reciprocal(); } // Add format metadata (if any) @@ -613,8 +699,10 @@ void FFmpegReader::Open() { previous_packet_location.sample_start = 0; // Adjust cache size based on size of frame and audio - working_cache.SetMaxBytesFromInfo(info.fps.ToDouble() * 2, info.width, info.height, info.sample_rate, info.channels); - final_cache.SetMaxBytesFromInfo(24, info.width, info.height, info.sample_rate, info.channels); + const int working_cache_frames = std::max(Settings::Instance()->CACHE_MIN_FRAMES, int(OPEN_MP_NUM_PROCESSORS * info.fps.ToDouble() * 2)); + const int final_cache_frames = std::max(Settings::Instance()->CACHE_MIN_FRAMES, OPEN_MP_NUM_PROCESSORS * 2); + working_cache.SetMaxBytesFromInfo(working_cache_frames, info.width, info.height, info.sample_rate, info.channels); + final_cache.SetMaxBytesFromInfo(final_cache_frames, info.width, info.height, info.sample_rate, info.channels); // Scan PTS for any offsets (i.e. non-zero starting streams). At least 1 stream must start at zero timestamp. // This method allows us to shift timestamps to ensure at least 1 stream is starting at zero. @@ -716,8 +804,7 @@ void FFmpegReader::Close() { avformat_close_input(&pFormatCtx); av_freep(&pFormatCtx); - // Release free’d arenas back to OS after heavy teardown - TrimMemoryToOS(true); + // Do not trim here; trimming is handled on explicit cache clears // Reset some variables last_frame = 0; @@ -727,6 +814,7 @@ void FFmpegReader::Close() { seek_video_frame_found = 0; current_video_frame = 0; last_video_frame.reset(); + last_final_video_frame.reset(); } } @@ -807,12 +895,20 @@ void FFmpegReader::ApplyDurationStrategy() { } void FFmpegReader::UpdateAudioInfo() { + const int codec_channels = +#if HAVE_CH_LAYOUT + AV_GET_CODEC_ATTRIBUTES(aStream, aCodecCtx)->ch_layout.nb_channels; +#else + AV_GET_CODEC_ATTRIBUTES(aStream, aCodecCtx)->channels; +#endif + // Set default audio channel layout (if needed) #if HAVE_CH_LAYOUT - if (!av_channel_layout_check(&(AV_GET_CODEC_ATTRIBUTES(aStream, aCodecCtx)->ch_layout))) + if (codec_channels > 0 && + !av_channel_layout_check(&(AV_GET_CODEC_ATTRIBUTES(aStream, aCodecCtx)->ch_layout))) AV_GET_CODEC_ATTRIBUTES(aStream, aCodecCtx)->ch_layout = (AVChannelLayout) AV_CHANNEL_LAYOUT_STEREO; #else - if (AV_GET_CODEC_ATTRIBUTES(aStream, aCodecCtx)->channel_layout == 0) + if (codec_channels > 0 && AV_GET_CODEC_ATTRIBUTES(aStream, aCodecCtx)->channel_layout == 0) AV_GET_CODEC_ATTRIBUTES(aStream, aCodecCtx)->channel_layout = av_get_default_channel_layout(AV_GET_CODEC_ATTRIBUTES(aStream, aCodecCtx)->channels); #endif @@ -1035,6 +1131,8 @@ bool FFmpegReader::GetIsDurationKnown() { } std::shared_ptr FFmpegReader::GetFrame(int64_t requested_frame) { + last_seek_max_frame = -1; + seek_stagnant_count = 0; // Check for open reader (or throw exception) if (!is_open) throw ReaderClosed("The FFmpegReader is closed. Call Open() before calling this method.", path); @@ -1105,6 +1203,11 @@ std::shared_ptr FFmpegReader::ReadStream(int64_t requested_frame) { // Allocate video frame bool check_seek = false; int packet_error = -1; + int64_t no_progress_count = 0; + int64_t prev_packets_read = packet_status.packets_read(); + int64_t prev_packets_decoded = packet_status.packets_decoded(); + int64_t prev_video_decoded = packet_status.video_decoded; + double prev_video_pts_seconds = video_pts_seconds; // Debug output ZmqLogger::Instance()->AppendDebugMethod("FFmpegReader::ReadStream", "requested_frame", requested_frame); @@ -1137,7 +1240,7 @@ std::shared_ptr FFmpegReader::ReadStream(int64_t requested_frame) { // Check the status of a seek (if any) if (is_seeking) { - check_seek = CheckSeek(false); + check_seek = CheckSeek(); } else { check_seek = false; } @@ -1196,6 +1299,40 @@ std::shared_ptr FFmpegReader::ReadStream(int64_t requested_frame) { packet_status.end_of_file = true; break; } + + // Detect decoder stalls with no progress at EOF and force completion so + // missing frames can be finalized from prior image data. + const bool has_progress = + (packet_status.packets_read() != prev_packets_read) || + (packet_status.packets_decoded() != prev_packets_decoded) || + (packet_status.video_decoded != prev_video_decoded) || + (video_pts_seconds != prev_video_pts_seconds); + + if (has_progress) { + no_progress_count = 0; + } else { + no_progress_count++; + if (no_progress_count >= 2000 + && packet_status.packets_eof + && !packet + && !hold_packet) { + ZmqLogger::Instance()->AppendDebugMethod("FFmpegReader::ReadStream (force EOF after stall)", + "requested_frame", requested_frame, + "no_progress_count", no_progress_count, + "packets_read", packet_status.packets_read(), + "packets_decoded", packet_status.packets_decoded(), + "video_decoded", packet_status.video_decoded, + "audio_decoded", packet_status.audio_decoded); + packet_status.video_eof = true; + packet_status.audio_eof = true; + packet_status.end_of_file = true; + break; + } + } + prev_packets_read = packet_status.packets_read(); + prev_packets_decoded = packet_status.packets_decoded(); + prev_video_decoded = packet_status.video_decoded; + prev_video_pts_seconds = video_pts_seconds; } // end while // Debug output @@ -1241,9 +1378,18 @@ std::shared_ptr FFmpegReader::ReadStream(int64_t requested_frame) { return frame; } else { - // The largest processed frame is no longer in cache, return a blank frame + // The largest processed frame is no longer in cache. Prefer the most recent + // finalized image first, then decoded image, to avoid black flashes. std::shared_ptr f = CreateFrame(largest_frame_processed); - f->AddColor(info.width, info.height, "#000"); + if (last_final_video_frame && last_final_video_frame->has_image_data + && last_final_video_frame->number <= requested_frame) { + f->AddImage(std::make_shared(last_final_video_frame->GetImage()->copy())); + } else if (last_video_frame && last_video_frame->has_image_data + && last_video_frame->number <= requested_frame) { + f->AddImage(std::make_shared(last_video_frame->GetImage()->copy())); + } else { + f->AddColor(info.width, info.height, "#000"); + } f->AddAudioSilence(samples_in_frame); return f; } @@ -1446,9 +1592,12 @@ bool FFmpegReader::GetAVFrame() { } // Check the current seek position and determine if we need to seek again -bool FFmpegReader::CheckSeek(bool is_video) { +bool FFmpegReader::CheckSeek() { // Are we seeking for a specific frame? if (is_seeking) { + const int64_t kSeekRetryMax = 5; + const int kSeekStagnantMax = 2; + // Determine if both an audio and video packet have been decoded since the seek happened. // If not, allow the ReadStream method to keep looping if ((is_video_seek && !seek_video_frame_found) || (!is_video_seek && !seek_audio_frame_found)) @@ -1460,6 +1609,13 @@ bool FFmpegReader::CheckSeek(bool is_video) { // Determine max seeked frame int64_t max_seeked_frame = std::max(seek_audio_frame_found, seek_video_frame_found); + // Track stagnant seek results (no progress between retries) + if (max_seeked_frame == last_seek_max_frame) { + seek_stagnant_count++; + } else { + last_seek_max_frame = max_seeked_frame; + seek_stagnant_count = 0; + } // determine if we are "before" the requested frame if (max_seeked_frame >= seeking_frame) { @@ -1473,7 +1629,15 @@ bool FFmpegReader::CheckSeek(bool is_video) { "seek_audio_frame_found", seek_audio_frame_found); // Seek again... to the nearest Keyframe - Seek(seeking_frame - (10 * seek_count * seek_count)); + if (seek_count < kSeekRetryMax) { + Seek(seeking_frame - (10 * seek_count * seek_count)); + } else if (seek_stagnant_count >= kSeekStagnantMax) { + // Stagnant seek: force a much earlier target and keep seeking. + Seek(seeking_frame - (10 * kSeekRetryMax * kSeekRetryMax)); + } else { + // Retry budget exhausted: keep seeking from a conservative offset. + Seek(seeking_frame - (10 * seek_count * seek_count)); + } } else { // SEEK WORKED ZmqLogger::Instance()->AppendDebugMethod("FFmpegReader::CheckSeek (Successful)", @@ -1938,6 +2102,7 @@ void FFmpegReader::Seek(int64_t requested_frame) { last_frame = 0; current_video_frame = 0; largest_frame_processed = 0; + last_final_video_frame.reset(); bool has_audio_override = info.has_audio; bool has_video_override = info.has_video; @@ -1980,7 +2145,7 @@ void FFmpegReader::Seek(int64_t requested_frame) { if (!seek_worked && info.has_video && !HasAlbumArt()) { seek_target = ConvertFrameToVideoPTS(requested_frame - buffer_amount); if (av_seek_frame(pFormatCtx, info.video_stream_index, seek_target, AVSEEK_FLAG_BACKWARD) < 0) { - fprintf(stderr, "%s: error while seeking video stream\n", pFormatCtx->AV_FILENAME); + ZmqLogger::Instance()->Log(std::string(pFormatCtx->AV_FILENAME) + ": error while seeking video stream"); } else { // VIDEO SEEK is_video_seek = true; @@ -1992,7 +2157,7 @@ void FFmpegReader::Seek(int64_t requested_frame) { if (!seek_worked && info.has_audio) { seek_target = ConvertFrameToAudioPTS(requested_frame - buffer_amount); if (av_seek_frame(pFormatCtx, info.audio_stream_index, seek_target, AVSEEK_FLAG_BACKWARD) < 0) { - fprintf(stderr, "%s: error while seeking audio stream\n", pFormatCtx->AV_FILENAME); + ZmqLogger::Instance()->Log(std::string(pFormatCtx->AV_FILENAME) + ": error while seeking audio stream"); } else { // AUDIO SEEK is_video_seek = false; @@ -2139,12 +2304,17 @@ void FFmpegReader::UpdatePTSOffset() { int64_t FFmpegReader::ConvertVideoPTStoFrame(int64_t pts) { // Apply PTS offset int64_t previous_video_frame = current_video_frame; + const double fps_value = (info.fps.num > 0 && info.fps.den > 0) ? info.fps.ToDouble() : 30.0; + const double video_timebase_value = + (info.video_timebase.num > 0 && info.video_timebase.den > 0) + ? info.video_timebase.ToDouble() + : (1.0 / 30.0); // Get the video packet start time (in seconds) - double video_seconds = (double(pts) * info.video_timebase.ToDouble()) + pts_offset_seconds; + double video_seconds = (double(pts) * video_timebase_value) + pts_offset_seconds; // Divide by the video timebase, to get the video frame number (frame # is decimal at this point) - int64_t frame = round(video_seconds * info.fps.ToDouble()) + 1; + int64_t frame = round(video_seconds * fps_value) + 1; // Keep track of the expected video frame # if (current_video_frame == 0) @@ -2167,11 +2337,17 @@ int64_t FFmpegReader::ConvertVideoPTStoFrame(int64_t pts) { // Convert Frame Number into Video PTS int64_t FFmpegReader::ConvertFrameToVideoPTS(int64_t frame_number) { + const double fps_value = (info.fps.num > 0 && info.fps.den > 0) ? info.fps.ToDouble() : 30.0; + const double video_timebase_value = + (info.video_timebase.num > 0 && info.video_timebase.den > 0) + ? info.video_timebase.ToDouble() + : (1.0 / 30.0); + // Get timestamp of this frame (in seconds) - double seconds = (double(frame_number - 1) / info.fps.ToDouble()) + pts_offset_seconds; + double seconds = (double(frame_number - 1) / fps_value) + pts_offset_seconds; // Calculate the # of video packets in this timestamp - int64_t video_pts = round(seconds / info.video_timebase.ToDouble()); + int64_t video_pts = round(seconds / video_timebase_value); // Apply PTS offset (opposite) return video_pts; @@ -2179,11 +2355,17 @@ int64_t FFmpegReader::ConvertFrameToVideoPTS(int64_t frame_number) { // Convert Frame Number into Video PTS int64_t FFmpegReader::ConvertFrameToAudioPTS(int64_t frame_number) { + const double fps_value = (info.fps.num > 0 && info.fps.den > 0) ? info.fps.ToDouble() : 30.0; + const double audio_timebase_value = + (info.audio_timebase.num > 0 && info.audio_timebase.den > 0) + ? info.audio_timebase.ToDouble() + : (1.0 / 48000.0); + // Get timestamp of this frame (in seconds) - double seconds = (double(frame_number - 1) / info.fps.ToDouble()) + pts_offset_seconds; + double seconds = (double(frame_number - 1) / fps_value) + pts_offset_seconds; // Calculate the # of audio packets in this timestamp - int64_t audio_pts = round(seconds / info.audio_timebase.ToDouble()); + int64_t audio_pts = round(seconds / audio_timebase_value); // Apply PTS offset (opposite) return audio_pts; @@ -2191,11 +2373,17 @@ int64_t FFmpegReader::ConvertFrameToAudioPTS(int64_t frame_number) { // Calculate Starting video frame and sample # for an audio PTS AudioLocation FFmpegReader::GetAudioPTSLocation(int64_t pts) { + const double audio_timebase_value = + (info.audio_timebase.num > 0 && info.audio_timebase.den > 0) + ? info.audio_timebase.ToDouble() + : (1.0 / 48000.0); + const double fps_value = (info.fps.num > 0 && info.fps.den > 0) ? info.fps.ToDouble() : 30.0; + // Get the audio packet start time (in seconds) - double audio_seconds = (double(pts) * info.audio_timebase.ToDouble()) + pts_offset_seconds; + double audio_seconds = (double(pts) * audio_timebase_value) + pts_offset_seconds; // Divide by the video timebase, to get the video frame number (frame # is decimal at this point) - double frame = (audio_seconds * info.fps.ToDouble()) + 1; + double frame = (audio_seconds * fps_value) + 1; // Frame # as a whole number (no more decimals) int64_t whole_frame = int64_t(frame); @@ -2324,26 +2512,39 @@ void FFmpegReader::CheckWorkingFrames(int64_t requested_frame) { // OR video stream is too far behind, missing, or end-of-file is_video_ready = true; ZmqLogger::Instance()->AppendDebugMethod("FFmpegReader::CheckWorkingFrames (video ready)", - "frame_number", f->number, - "frame_pts_seconds", frame_pts_seconds, - "video_pts_seconds", video_pts_seconds, - "recent_pts_diff", recent_pts_diff); + "frame_number", f->number, + "frame_pts_seconds", frame_pts_seconds, + "video_pts_seconds", video_pts_seconds, + "recent_pts_diff", recent_pts_diff); if (info.has_video && !f->has_image_data) { - // Frame has no image data (copy from previous frame) - // Loop backwards through final frames (looking for the nearest, previous frame image) - for (int64_t previous_frame = requested_frame - 1; previous_frame > 0; previous_frame--) { - std::shared_ptr previous_frame_instance = final_cache.GetFrame(previous_frame); - if (previous_frame_instance && previous_frame_instance->has_image_data) { - // Copy image from last decoded frame - f->AddImage(std::make_shared(previous_frame_instance->GetImage()->copy())); - break; - } + // Frame has no image data. Prefer timeline-previous frames to preserve + // visual order, especially when decode/prefetch is out-of-order. + std::shared_ptr previous_frame_instance = final_cache.GetFrame(f->number - 1); + if (previous_frame_instance && previous_frame_instance->has_image_data) { + f->AddImage(std::make_shared(previous_frame_instance->GetImage()->copy())); } - - if (last_video_frame && !f->has_image_data) { - // Copy image from last decoded frame + + // Fall back to last finalized timeline image (survives cache churn). + if (!f->has_image_data + && last_final_video_frame + && last_final_video_frame->has_image_data + && last_final_video_frame->number <= f->number) { + f->AddImage(std::make_shared(last_final_video_frame->GetImage()->copy())); + } + + // Fall back to the last decoded image only when it is not from the future. + if (!f->has_image_data + && last_video_frame + && last_video_frame->has_image_data + && last_video_frame->number <= f->number) { f->AddImage(std::make_shared(last_video_frame->GetImage()->copy())); - } else if (!f->has_image_data) { + } + + // Last-resort fallback if no prior image is available. + if (!f->has_image_data) { + ZmqLogger::Instance()->AppendDebugMethod( + "FFmpegReader::CheckWorkingFrames (no previous image found; using black frame)", + "frame_number", f->number); f->AddColor("#000000"); } } @@ -2393,6 +2594,9 @@ void FFmpegReader::CheckWorkingFrames(int64_t requested_frame) { if (!is_seek_trash) { // Move frame to final cache final_cache.Add(f); + if (f->has_image_data) { + last_final_video_frame = f; + } // Remove frame from working cache working_cache.Remove(f->number); diff --git a/src/FFmpegReader.h b/src/FFmpegReader.h index f264754c..7b42c9d4 100644 --- a/src/FFmpegReader.h +++ b/src/FFmpegReader.h @@ -128,6 +128,7 @@ namespace openshot { int64_t pts_total; int64_t pts_counter; std::shared_ptr last_video_frame; + std::shared_ptr last_final_video_frame; bool is_seeking; int64_t seeking_pts; @@ -136,6 +137,8 @@ namespace openshot { int seek_count; int64_t seek_audio_frame_found; int64_t seek_video_frame_found; + int64_t last_seek_max_frame; + int seek_stagnant_count; int64_t last_frame; int64_t largest_frame_processed; @@ -172,7 +175,7 @@ namespace openshot { void CheckFPS(); /// Check the current seek position and determine if we need to seek again - bool CheckSeek(bool is_video); + bool CheckSeek(); /// Check the working queue, and move finished frames to the finished queue void CheckWorkingFrames(int64_t requested_frame); diff --git a/src/Frame.cpp b/src/Frame.cpp index 2e785fb2..b24a913d 100644 --- a/src/Frame.cpp +++ b/src/Frame.cpp @@ -455,9 +455,9 @@ void Frame::SetFrameNumber(int64_t new_number) // Calculate the # of samples per video frame (for a specific frame number and frame rate) int Frame::GetSamplesPerFrame(int64_t number, Fraction fps, int sample_rate, int channels) { - // Directly return 0 if there are no channels + // Directly return 0 for invalid audio/frame-rate parameters // so that we do not need to deal with NaNs later - if (channels == 0) return 0; + if (channels <= 0 || sample_rate <= 0 || fps.num <= 0 || fps.den <= 0) return 0; // Get the total # of samples for the previous frame, and the current frame (rounded) double fps_rate = fps.Reciprocal().ToDouble(); diff --git a/src/FrameMapper.cpp b/src/FrameMapper.cpp index 531cd571..5e678b5b 100644 --- a/src/FrameMapper.cpp +++ b/src/FrameMapper.cpp @@ -11,6 +11,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later #include +#include #include #include @@ -49,7 +50,8 @@ FrameMapper::FrameMapper(ReaderBase *reader, Fraction target, PulldownType targe field_toggle = true; // Adjust cache size based on size of frame and audio - final_cache.SetMaxBytesFromInfo(OPEN_MP_NUM_PROCESSORS, info.width, info.height, info.sample_rate, info.channels); + const int initial_cache_frames = std::max(Settings::Instance()->CACHE_MIN_FRAMES, OPEN_MP_NUM_PROCESSORS); + final_cache.SetMaxBytesFromInfo(initial_cache_frames, info.width, info.height, info.sample_rate, info.channels); } // Destructor @@ -746,9 +748,6 @@ void FrameMapper::Close() SWR_FREE(&avr); avr = NULL; } - - // Release free’d arenas back to OS after heavy teardown - TrimMemoryToOS(true); } @@ -845,7 +844,8 @@ void FrameMapper::ChangeMapping(Fraction target_fps, PulldownType target_pulldow final_cache.Clear(); // Adjust cache size based on size of frame and audio - final_cache.SetMaxBytesFromInfo(24, info.width, info.height, info.sample_rate, info.channels); + const int reset_cache_frames = std::max(Settings::Instance()->CACHE_MIN_FRAMES, OPEN_MP_NUM_PROCESSORS * 4); + final_cache.SetMaxBytesFromInfo(reset_cache_frames, info.width, info.height, info.sample_rate, info.channels); // Deallocate resample buffer if (avr) { diff --git a/src/MemoryTrim.cpp b/src/MemoryTrim.cpp index 8a73ec50..50aae55c 100644 --- a/src/MemoryTrim.cpp +++ b/src/MemoryTrim.cpp @@ -25,7 +25,7 @@ namespace { // Limit trim attempts to once per interval to avoid spamming platform calls -constexpr uint64_t kMinTrimIntervalMs = 1000; // 1s debounce +constexpr uint64_t kMinTrimIntervalMs = 30000; // 30s debounce std::atomic g_last_trim_ms{0}; std::atomic g_trim_in_progress{false}; @@ -37,12 +37,12 @@ uint64_t NowMs() { namespace openshot { -bool TrimMemoryToOS(bool force) noexcept { +bool TrimMemoryToOS() noexcept { const uint64_t now_ms = NowMs(); const uint64_t last_ms = g_last_trim_ms.load(std::memory_order_relaxed); - // Skip if we recently trimmed (unless forced) - if (!force && now_ms - last_ms < kMinTrimIntervalMs) + // Skip if we recently trimmed + if (now_ms - last_ms < kMinTrimIntervalMs) return false; // Only one trim attempt runs at a time @@ -70,8 +70,9 @@ bool TrimMemoryToOS(bool force) noexcept { did_trim = false; #endif - if (did_trim) + if (did_trim) { g_last_trim_ms.store(now_ms, std::memory_order_relaxed); + } g_trim_in_progress.store(false, std::memory_order_release); return did_trim; diff --git a/src/MemoryTrim.h b/src/MemoryTrim.h index 943fa0ae..aa99d969 100644 --- a/src/MemoryTrim.h +++ b/src/MemoryTrim.h @@ -25,6 +25,6 @@ namespace openshot { * @param force If true, bypass the debounce interval (useful for teardown). * @return true if a platform-specific trim call was made, false otherwise. */ -bool TrimMemoryToOS(bool force = false) noexcept; +bool TrimMemoryToOS() noexcept; } // namespace openshot diff --git a/src/Qt/VideoCacheThread.cpp b/src/Qt/VideoCacheThread.cpp index 643ed0a6..6513e692 100644 --- a/src/Qt/VideoCacheThread.cpp +++ b/src/Qt/VideoCacheThread.cpp @@ -29,6 +29,7 @@ namespace openshot , last_speed(1) , last_dir(1) // assume forward (+1) on first launch , userSeeked(false) + , preroll_on_next_fill(false) , requested_display_frame(1) , current_display_frame(1) , cached_frame_count(0) @@ -52,21 +53,47 @@ namespace openshot return false; } - if (min_frames_ahead < 0) { + const int64_t ready_min = min_frames_ahead.load(); + if (ready_min < 0) { return true; } - return (cached_frame_count > min_frames_ahead); + 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(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(0, max_frame - playhead) + : std::max(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 = new_speed; - last_dir = (new_speed > 0 ? 1 : -1); + last_speed.store(new_speed); + last_dir.store(new_speed > 0 ? 1 : -1); } - speed = new_speed; + speed.store(new_speed); } // Get the size in bytes of a frame (rough estimate) @@ -101,24 +128,38 @@ namespace openshot void VideoCacheThread::Seek(int64_t new_position, bool start_preroll) { - if (start_preroll) { - userSeeked = true; + bool should_mark_seek = false; + bool should_preroll = false; + int64_t new_cached_count = cached_frame_count.load(); + 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 ( - Timeline* timeline = static_cast(reader); - timeline->ClearAllCache(); - cached_frame_count = 0; + if (Timeline* timeline = dynamic_cast(reader)) { + timeline->ClearAllCache(); + } + new_cached_count = 0; + should_preroll = true; } else if (cache) { - cached_frame_count = cache->Count(); + new_cached_count = cache->Count(); + } + } + + { + std::lock_guard guard(seek_state_mutex); + 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); } } - requested_display_frame = new_position; } void VideoCacheThread::Seek(int64_t new_position) @@ -129,13 +170,50 @@ namespace openshot 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); + 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 = playhead - 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(1, playhead - preroll_frames); + } + else { + preroll_start = std::min(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, @@ -144,9 +222,10 @@ namespace openshot { if (paused && !cache->Contains(playhead)) { // If paused and playhead not in cache, clear everything - Timeline* timeline = static_cast(reader); - timeline->ClearAllCache(); - cached_frame_count = 0; + if (Timeline* timeline = dynamic_cast(reader)) { + timeline->ClearAllCache(); + } + cached_frame_count.store(0); return true; } return false; @@ -181,7 +260,7 @@ namespace openshot ReaderBase* reader) { bool window_full = true; - int64_t next_frame = last_cached_index + dir; + int64_t next_frame = last_cached_index.load() + dir; // Advance from last_cached_index toward window boundary while ((dir > 0 && next_frame <= window_end) || @@ -191,7 +270,7 @@ namespace openshot break; } // If a Seek was requested mid-caching, bail out immediately - if (userSeeked) { + if (userSeeked.load()) { break; } @@ -200,7 +279,7 @@ namespace openshot try { auto framePtr = reader->GetFrame(next_frame); cache->Add(framePtr); - cached_frame_count = cache->Count(); + cached_frame_count.store(cache->Count()); } catch (const OutOfBoundsFrame&) { break; @@ -211,7 +290,7 @@ namespace openshot cache->Touch(next_frame); } - last_cached_index = next_frame; + last_cached_index.store(next_frame); next_frame += dir; } @@ -229,26 +308,31 @@ namespace openshot // If caching disabled or no reader, mark cache as ready and sleep briefly if (!settings->ENABLE_PLAYBACK_CACHING || !cache) { - cached_frame_count = (cache ? cache->Count() : 0); - min_frames_ahead = -1; + 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 = settings->VIDEO_CACHE_MIN_PREROLL_FRAMES; + min_frames_ahead.store(settings->VIDEO_CACHE_MIN_PREROLL_FRAMES); - Timeline* timeline = static_cast(reader); + Timeline* timeline = dynamic_cast(reader); + if (!timeline) { + std::this_thread::sleep_for(double_micro_sec(50000)); + continue; + } int64_t timeline_end = timeline->GetMaxFrame(); - int64_t playhead = requested_display_frame; - bool paused = (speed == 0); + int64_t playhead = requested_display_frame.load(); + bool paused = (speed.load() == 0); + int64_t preroll_frames = computePrerollFrames(settings); - cached_frame_count = cache->Count(); + cached_frame_count.store(cache->Count()); // Compute effective direction (±1) int dir = computeDirection(); - if (speed != 0) { - last_dir = dir; + if (speed.load() != 0) { + last_dir.store(dir); } // Compute bytes_per_frame, max_bytes, and capacity once @@ -269,9 +353,25 @@ namespace openshot } // Handle a user-initiated seek - if (userSeeked) { - handleUserSeek(playhead, dir); - userSeeked = false; + bool did_user_seek = false; + bool use_preroll = false; + { + std::lock_guard 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) { + if (use_preroll) { + 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 @@ -288,8 +388,8 @@ namespace openshot ); bool outside_window = - (dir > 0 && last_cached_index > window_end) || - (dir < 0 && last_cached_index < window_begin); + (dir > 0 && last_cached_index.load() > window_end) || + (dir < 0 && last_cached_index.load() < window_begin); if (outside_window) { handleUserSeek(playhead, dir); } @@ -311,12 +411,12 @@ namespace openshot ready_target = 0; } int64_t configured_min = settings->VIDEO_CACHE_MIN_PREROLL_FRAMES; - min_frames_ahead = std::min(configured_min, ready_target); + min_frames_ahead.store(std::min(configured_min, ready_target)); // If paused and playhead is no longer in cache, clear everything bool did_clear = clearCacheIfPaused(playhead, paused, cache); if (did_clear) { - handleUserSeek(playhead, dir); + handleUserSeekWithPreroll(playhead, dir, timeline_end, preroll_frames); } // Compute the current caching window diff --git a/src/Qt/VideoCacheThread.h b/src/Qt/VideoCacheThread.h index 72f502e0..a9dc2dd6 100644 --- a/src/Qt/VideoCacheThread.h +++ b/src/Qt/VideoCacheThread.h @@ -17,10 +17,13 @@ #include #include +#include +#include #include namespace openshot { + class Settings; using juce::Thread; /** @@ -56,7 +59,7 @@ namespace openshot void setSpeed(int new_speed); /// @return The current speed (1=normal, 2=fast, –1=rewind, etc.) - int getSpeed() const { return speed; } + int getSpeed() const { return speed.load(); } /// Seek to a specific frame (no preroll). void Seek(int64_t new_position); @@ -107,6 +110,21 @@ namespace openshot */ void handleUserSeek(int64_t playhead, int dir); + /** + * @brief Reset last_cached_index to start caching with a directional preroll offset. + * @param playhead Current requested_display_frame + * @param dir Effective direction (±1) + * @param timeline_end Last valid frame index + * @param preroll_frames Number of frames to offset the cache start + */ + void handleUserSeekWithPreroll(int64_t playhead, + int dir, + int64_t timeline_end, + int64_t preroll_frames); + + /// @brief Compute preroll frame count from settings. + int64_t computePrerollFrames(const Settings* settings) const; + /** * @brief When paused and playhead is outside current cache, clear all frames. * @param playhead Current requested_display_frame @@ -159,22 +177,24 @@ namespace openshot 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 userSeeked; ///< True if Seek(..., true) was called (forces a cache reset). + std::atomic speed; ///< Current playback speed (0=paused, >0 forward, <0 backward). + std::atomic last_speed; ///< Last non-zero speed (for tracking). + std::atomic last_dir; ///< Last direction sign (+1 forward, –1 backward). + std::atomic userSeeked; ///< True if Seek(..., true) was called (forces a cache reset). + std::atomic preroll_on_next_fill; ///< True if next cache rebuild should include preroll offset. - int64_t requested_display_frame; ///< Frame index the user requested. + std::atomic requested_display_frame; ///< Frame index the user requested. int64_t current_display_frame; ///< Currently displayed frame (unused here, reserved). - int64_t cached_frame_count; ///< Estimated count of frames currently stored in cache. + std::atomic cached_frame_count; ///< Estimated count of frames currently stored in cache. - int64_t min_frames_ahead; ///< Minimum number of frames considered “ready” (pre-roll). + std::atomic min_frames_ahead; ///< Minimum number of frames considered “ready” (pre-roll). 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. + std::atomic last_cached_index; ///< Index of the most recently cached frame. + mutable std::mutex seek_state_mutex; ///< Protects coherent seek state updates/consumption. }; } // namespace openshot diff --git a/src/Settings.h b/src/Settings.h index 0474e3d1..7ad8eb96 100644 --- a/src/Settings.h +++ b/src/Settings.h @@ -67,11 +67,14 @@ namespace openshot { /// Number of threads of OpenMP int OMP_THREADS = 16; - /// Number of threads that ffmpeg uses - int FF_THREADS = 16; + /// Number of threads that ffmpeg uses + int FF_THREADS = 16; - /// Maximum rows that hardware decode can handle - int DE_LIMIT_HEIGHT_MAX = 1100; + /// Minimum number of frames for frame-count-based caches + int CACHE_MIN_FRAMES = 24; + + /// Maximum rows that hardware decode can handle + int DE_LIMIT_HEIGHT_MAX = 1100; /// Maximum columns that hardware decode can handle int DE_LIMIT_WIDTH_MAX = 1950; @@ -86,10 +89,10 @@ namespace openshot { float VIDEO_CACHE_PERCENT_AHEAD = 0.7; /// Minimum number of frames to cache before playback begins - int VIDEO_CACHE_MIN_PREROLL_FRAMES = 24; + int VIDEO_CACHE_MIN_PREROLL_FRAMES = 30; /// Max number of frames (ahead of playhead) to cache during playback - int VIDEO_CACHE_MAX_PREROLL_FRAMES = 48; + int VIDEO_CACHE_MAX_PREROLL_FRAMES = 60; /// Max number of frames (when paused) to cache for playback int VIDEO_CACHE_MAX_FRAMES = 30 * 10; diff --git a/src/Timeline.cpp b/src/Timeline.cpp index 26fa96bd..3cf0bd67 100644 --- a/src/Timeline.cpp +++ b/src/Timeline.cpp @@ -19,6 +19,7 @@ #include "FrameMapper.h" #include "Exceptions.h" +#include #include #include #include @@ -69,7 +70,8 @@ Timeline::Timeline(int width, int height, Fraction fps, int sample_rate, int cha // Init cache final_cache = new CacheMemory(); - final_cache->SetMaxBytesFromInfo(24, info.width, info.height, info.sample_rate, info.channels); + const int cache_frames = std::max(Settings::Instance()->CACHE_MIN_FRAMES, OPEN_MP_NUM_PROCESSORS * 4); + final_cache->SetMaxBytesFromInfo(cache_frames, info.width, info.height, info.sample_rate, info.channels); } // Delegating constructor that copies parameters from a provided ReaderInfo @@ -201,7 +203,8 @@ Timeline::Timeline(const std::string& projectPath, bool convert_absolute_paths) // Init cache final_cache = new CacheMemory(); - final_cache->SetMaxBytesFromInfo(24, info.width, info.height, info.sample_rate, info.channels); + const int cache_frames = std::max(Settings::Instance()->CACHE_MIN_FRAMES, OPEN_MP_NUM_PROCESSORS * 4); + final_cache->SetMaxBytesFromInfo(cache_frames, info.width, info.height, info.sample_rate, info.channels); } Timeline::~Timeline() { @@ -1458,6 +1461,11 @@ void Timeline::apply_json_to_clips(Json::Value change) { // Add clip to timeline AddClip(clip); + // Calculate start and end frames that this impacts, and remove those frames from the cache + int64_t new_starting_frame = (clip->Position() * info.fps.ToDouble()) + 1; + int64_t new_ending_frame = ((clip->Position() + clip->Duration()) * info.fps.ToDouble()) + 1; + final_cache->Remove(new_starting_frame - 8, new_ending_frame + 8); + } else if (change_type == "update") { // Update existing clip @@ -1744,6 +1752,8 @@ void Timeline::apply_json_to_timeline(Json::Value change) { // Clear all caches void Timeline::ClearAllCache(bool deep) { + // Get lock (prevent getting frames while this happens) + const std::lock_guard guard(getFrameMutex); // Clear primary cache if (final_cache) { diff --git a/src/Timeline.h b/src/Timeline.h index 46cdfb31..83db07ec 100644 --- a/src/Timeline.h +++ b/src/Timeline.h @@ -74,13 +74,13 @@ namespace openshot { /// the Clip with the highest end-frame number using std::max_element struct CompareClipEndFrames { bool operator()(const openshot::Clip* lhs, const openshot::Clip* rhs) { - return (lhs->Position() + lhs->Duration()) <= (rhs->Position() + rhs->Duration()); + return (lhs->Position() + lhs->Duration()) < (rhs->Position() + rhs->Duration()); }}; /// Like CompareClipEndFrames, but for effects struct CompareEffectEndFrames { bool operator()(const openshot::EffectBase* lhs, const openshot::EffectBase* rhs) { - return (lhs->Position() + lhs->Duration()) <= (rhs->Position() + rhs->Duration()); + return (lhs->Position() + lhs->Duration()) < (rhs->Position() + rhs->Duration()); }}; /** diff --git a/tests/Frame.cpp b/tests/Frame.cpp index ffe4d84d..d4d58ab9 100644 --- a/tests/Frame.cpp +++ b/tests/Frame.cpp @@ -138,6 +138,14 @@ TEST_CASE( "Copy_Constructor", "[libopenshot][frame]" ) CHECK(f1.GetAudioSamplesCount() == f2.GetAudioSamplesCount()); } +TEST_CASE( "GetSamplesPerFrame invalid rate inputs", "[libopenshot][frame]" ) +{ + CHECK(Frame::GetSamplesPerFrame(/*frame_number=*/1, Fraction(0, 1), /*sample_rate=*/44100, /*channels=*/2) == 0); + CHECK(Frame::GetSamplesPerFrame(/*frame_number=*/1, Fraction(30, 0), /*sample_rate=*/44100, /*channels=*/2) == 0); + CHECK(Frame::GetSamplesPerFrame(/*frame_number=*/1, Fraction(30, 1), /*sample_rate=*/0, /*channels=*/2) == 0); + CHECK(Frame::GetSamplesPerFrame(/*frame_number=*/1, Fraction(30, 1), /*sample_rate=*/44100, /*channels=*/0) == 0); +} + #ifdef USE_OPENCV TEST_CASE( "Convert_Image", "[libopenshot][opencv][frame]" ) { diff --git a/tests/Timeline.cpp b/tests/Timeline.cpp index 55f93e74..0c90fbd7 100644 --- a/tests/Timeline.cpp +++ b/tests/Timeline.cpp @@ -722,6 +722,40 @@ TEST_CASE( "GetMinFrame and GetMinTime", "[libopenshot][timeline]" ) CHECK(t.GetMinFrame() == (5 * 30) + 1); } +TEST_CASE( "GetMaxFrame with 24fps clip mapped to 30fps timeline", "[libopenshot][timeline]" ) +{ + Timeline t(640, 480, Fraction(30, 1), 44100, 2, LAYOUT_STEREO); + t.AutoMapClips(true); + + std::stringstream path; + path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4"; + Clip clip(path.str()); + + REQUIRE(clip.Reader()->info.fps.num == 24); + REQUIRE(clip.Reader()->info.fps.den == 1); + + t.AddClip(&clip); + + REQUIRE(clip.Reader()->Name() == "FrameMapper"); + auto* mapper = static_cast(clip.Reader()); + REQUIRE(mapper->info.fps.num == 30); + REQUIRE(mapper->info.fps.den == 1); + REQUIRE(mapper->info.video_length > 0); + + const int64_t timeline_max_frame = t.GetMaxFrame(); + const int64_t mapped_video_length = mapper->info.video_length; + + // Timeline max frame is computed from duration (seconds), while mapper length is + // rounded frame count. They should stay aligned within one frame at this boundary. + CHECK(timeline_max_frame >= mapped_video_length); + CHECK((timeline_max_frame - mapped_video_length) <= 1); + + // Regression guard: fetching the mapped tail frame should not throw. + t.Open(); + CHECK_NOTHROW(t.GetFrame(mapped_video_length)); + t.Close(); +} + TEST_CASE( "Multi-threaded Timeline GetFrame", "[libopenshot][timeline]" ) { Timeline *t = new Timeline(1280, 720, Fraction(24, 1), 48000, 2, LAYOUT_STEREO); @@ -1010,6 +1044,29 @@ TEST_CASE( "ApplyJSONDiff and FrameMappers", "[libopenshot][timeline]" ) CHECK(clip1.Reader()->Name() == "QtImageReader"); } +TEST_CASE( "ApplyJSONDiff insert invalidates overlapping timeline cache", "[libopenshot][timeline]" ) +{ + // Create timeline with no clips so cached frames are black placeholders + Timeline t(640, 480, Fraction(30, 1), 44100, 2, LAYOUT_STEREO); + t.Open(); + + // Cache a frame in the area where we'll insert a new clip + std::shared_ptr cached_before = t.GetFrame(10); + REQUIRE(cached_before != nullptr); + REQUIRE(t.GetCache() != nullptr); + REQUIRE(t.GetCache()->Contains(10)); + + // Insert clip via JSON diff overlapping frame 10 + std::stringstream path1; + path1 << TEST_MEDIA_PATH << "interlaced.png"; + std::stringstream json_change; + json_change << "[{\"type\":\"insert\",\"key\":[\"clips\"],\"value\":{\"id\":\"INSERT_CACHE_INVALIDATE\",\"layer\":1,\"position\":0.0,\"start\":0,\"end\":10,\"reader\":{\"acodec\":\"\",\"audio_bit_rate\":0,\"audio_stream_index\":-1,\"audio_timebase\":{\"den\":1,\"num\":1},\"channel_layout\":4,\"channels\":0,\"display_ratio\":{\"den\":1,\"num\":1},\"duration\":3600.0,\"file_size\":\"160000\",\"fps\":{\"den\":1,\"num\":30},\"has_audio\":false,\"has_single_image\":true,\"has_video\":true,\"height\":200,\"interlaced_frame\":false,\"metadata\":{},\"path\":\"" << path1.str() << "\",\"pixel_format\":-1,\"pixel_ratio\":{\"den\":1,\"num\":1},\"sample_rate\":0,\"top_field_first\":true,\"type\":\"QtImageReader\",\"vcodec\":\"\",\"video_bit_rate\":0,\"video_length\":\"108000\",\"video_stream_index\":-1,\"video_timebase\":{\"den\":30,\"num\":1},\"width\":200}},\"partial\":false}]"; + t.ApplyJsonDiff(json_change.str()); + + // Overlapping cached frame should be invalidated + CHECK(!t.GetCache()->Contains(10)); +} + TEST_CASE( "ApplyJSONDiff Update Reader Info", "[libopenshot][timeline]" ) { // Create a timeline diff --git a/tests/VideoCacheThread.cpp b/tests/VideoCacheThread.cpp index 74bd5c62..5493a141 100644 --- a/tests/VideoCacheThread.cpp +++ b/tests/VideoCacheThread.cpp @@ -33,11 +33,15 @@ public: using VideoCacheThread::clearCacheIfPaused; using VideoCacheThread::prefetchWindow; using VideoCacheThread::handleUserSeek; + using VideoCacheThread::handleUserSeekWithPreroll; + using VideoCacheThread::computePrerollFrames; - 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; } + int64_t getLastCachedIndex() const { return last_cached_index.load(); } + void setLastCachedIndex(int64_t v) { last_cached_index.store(v); } + void setPlayhead(int64_t v) { requested_display_frame.store(v); } + void setMinFramesAhead(int64_t v) { min_frames_ahead.store(v); } + void setLastDir(int d) { last_dir.store(d); } + void forceUserSeekFlag() { userSeeked.store(true); } }; // ---------------------------------------------------------------------------- @@ -95,6 +99,66 @@ TEST_CASE("computeWindowBounds: forward and backward bounds, clamped", "[VideoCa CHECK(we == 3); } +TEST_CASE("isReady: requires cached frames ahead of playhead", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + + Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(60,1), + /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO); + thread.Reader(&timeline); + + thread.setMinFramesAhead(30); + thread.setPlayhead(200); + thread.setSpeed(1); + + thread.setLastCachedIndex(200); + CHECK(!thread.isReady()); + + thread.setLastCachedIndex(229); + CHECK(!thread.isReady()); + + thread.setLastCachedIndex(230); + CHECK(thread.isReady()); + + thread.setSpeed(-1); + thread.setLastCachedIndex(200); + CHECK(!thread.isReady()); + + thread.setLastCachedIndex(171); + CHECK(!thread.isReady()); + + thread.setLastCachedIndex(170); + CHECK(thread.isReady()); +} + +TEST_CASE("isReady: clamps preroll requirement at timeline boundaries", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + + Timeline timeline(/*width=*/1280, /*height=*/720, /*fps=*/Fraction(30,1), + /*sample_rate=*/48000, /*channels=*/2, ChannelLayout::LAYOUT_STEREO); + thread.Reader(&timeline); + + const int64_t end = timeline.info.video_length; + REQUIRE(end > 10); + + // Forward near end: only a few frames remain, so don't require full preroll. + thread.setMinFramesAhead(30); + thread.setSpeed(1); + thread.setPlayhead(end - 5); + thread.setLastCachedIndex(end - 4); + CHECK(!thread.isReady()); + thread.setLastCachedIndex(end); + CHECK(thread.isReady()); + + // Backward near start: only a few frames exist behind playhead. + thread.setMinFramesAhead(30); + thread.setSpeed(-1); + thread.setPlayhead(3); + thread.setLastCachedIndex(2); + CHECK(!thread.isReady()); + thread.setLastCachedIndex(1); + CHECK(thread.isReady()); +} + TEST_CASE("clearCacheIfPaused: clears only when paused and not in cache", "[VideoCacheThread]") { TestableVideoCacheThread thread; CacheMemory cache(/*max_bytes=*/100000000); @@ -139,6 +203,22 @@ TEST_CASE("handleUserSeek: sets last_cached_index to playhead - dir", "[VideoCac CHECK(thread.getLastCachedIndex() == 51); } +TEST_CASE("handleUserSeekWithPreroll: offsets start by preroll frames", "[VideoCacheThread]") { + TestableVideoCacheThread thread; + + thread.handleUserSeekWithPreroll(/*playhead=*/60, /*dir=*/1, /*timeline_end=*/200, /*preroll_frames=*/30); + CHECK(thread.getLastCachedIndex() == 29); + + thread.handleUserSeekWithPreroll(/*playhead=*/10, /*dir=*/1, /*timeline_end=*/200, /*preroll_frames=*/30); + CHECK(thread.getLastCachedIndex() == 0); + + thread.handleUserSeekWithPreroll(/*playhead=*/1, /*dir=*/1, /*timeline_end=*/200, /*preroll_frames=*/30); + CHECK(thread.getLastCachedIndex() == 0); + + thread.handleUserSeekWithPreroll(/*playhead=*/60, /*dir=*/-1, /*timeline_end=*/200, /*preroll_frames=*/30); + CHECK(thread.getLastCachedIndex() == 91); +} + TEST_CASE("prefetchWindow: forward caching with FFmpegReader & CacheMemory", "[VideoCacheThread]") { TestableVideoCacheThread thread; CacheMemory cache(/*max_bytes=*/100000000);