diff --git a/src/Qt/VideoCacheThread.cpp b/src/Qt/VideoCacheThread.cpp index 80c4fa639..8e126dc59 100644 --- a/src/Qt/VideoCacheThread.cpp +++ b/src/Qt/VideoCacheThread.cpp @@ -58,10 +58,32 @@ namespace openshot } 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 = min_frames_ahead; + if (required_ahead < 0) { + required_ahead = 0; + } + int64_t available_ahead = (dir > 0) + ? std::max(0, max_frame - requested_display_frame) + : std::max(0, requested_display_frame - 1); + required_ahead = std::min(required_ahead, available_ahead); + if (dir > 0) { - return (last_cached_index >= requested_display_frame + min_frames_ahead); + return (last_cached_index >= requested_display_frame + required_ahead); } - return (last_cached_index <= requested_display_frame - min_frames_ahead); + return (last_cached_index <= requested_display_frame - required_ahead); } void VideoCacheThread::setSpeed(int new_speed) diff --git a/src/Timeline.h b/src/Timeline.h index 46cdfb315..83db07ec3 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/Timeline.cpp b/tests/Timeline.cpp index 55f93e741..df9983a02 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); diff --git a/tests/VideoCacheThread.cpp b/tests/VideoCacheThread.cpp index c918ea09a..31d3cc51b 100644 --- a/tests/VideoCacheThread.cpp +++ b/tests/VideoCacheThread.cpp @@ -130,6 +130,35 @@ TEST_CASE("isReady: requires cached frames ahead of playhead", "[VideoCacheThrea 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);