132 lines
5.3 KiB
C#
132 lines
5.3 KiB
C#
using Sdcb.FFmpeg.Utils;
|
|
using T3.Core.Video;
|
|
using Xunit;
|
|
|
|
namespace T3.VideoServices.Tests;
|
|
|
|
/// <summary>
|
|
/// Covers the per-stream frame cache: a cached frame is byte-identical to a fresh decode, lookups miss
|
|
/// cleanly, the byte budget caps retention, and least-recently-used frames evict first.
|
|
/// </summary>
|
|
public class VideoFrameCacheTests
|
|
{
|
|
[Fact]
|
|
public void Get_AfterAdd_ReturnsIdenticalFrame()
|
|
{
|
|
using var session = VideoDecoderSession.TryOpen(TestAssets.Video720p, VideoPlaybackOptimization.FastSeeking, out _);
|
|
Assert.NotNull(session);
|
|
var frameBytes = FrameBytes(session!);
|
|
|
|
var target = TimeToFrameMapper.SecondsToPts(2.0, session.StreamStartPts, session.TimeBaseNum, session.TimeBaseDen);
|
|
Assert.True(session.SeekAndDecodeTo(target, out _));
|
|
var expected = LumaChecksum(session.CurrentFrame);
|
|
|
|
using var cache = new VideoFrameCache(64L * 1024 * 1024);
|
|
cache.Add(target, session.CurrentFrame, frameBytes);
|
|
|
|
// Move the decoder away so CurrentFrame no longer holds the target — the hit must come from the cache,
|
|
// proving the retained clone keeps its own buffer ref after the decoder reuses its frame.
|
|
var elsewhere = TimeToFrameMapper.SecondsToPts(5.0, session.StreamStartPts, session.TimeBaseNum, session.TimeBaseDen);
|
|
Assert.True(session.SeekAndDecodeTo(elsewhere, out _));
|
|
|
|
Assert.True(cache.TryGet(target, out var cached));
|
|
Assert.Equal(expected, LumaChecksum(cached));
|
|
}
|
|
|
|
[Fact]
|
|
public void Get_Missing_ReturnsFalse()
|
|
{
|
|
using var cache = new VideoFrameCache(64L * 1024 * 1024);
|
|
Assert.False(cache.TryGet(123, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void Add_SamePtsTwice_DoesNotDoubleCount()
|
|
{
|
|
using var session = VideoDecoderSession.TryOpen(TestAssets.Video720p, VideoPlaybackOptimization.FastSeeking, out _);
|
|
Assert.NotNull(session);
|
|
var frameBytes = FrameBytes(session!);
|
|
Assert.True(session.TryReadNextFrame(out var pts));
|
|
|
|
using var cache = new VideoFrameCache(64L * 1024 * 1024);
|
|
cache.Add(pts, session.CurrentFrame, frameBytes);
|
|
cache.Add(pts, session.CurrentFrame, frameBytes);
|
|
|
|
Assert.Equal(1, cache.Count);
|
|
Assert.Equal(frameBytes, cache.ByteCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Budget_EvictsLeastRecentlyUsed()
|
|
{
|
|
using var session = VideoDecoderSession.TryOpen(TestAssets.Video720p, VideoPlaybackOptimization.FastSeeking, out _);
|
|
Assert.NotNull(session);
|
|
var frameBytes = FrameBytes(session!);
|
|
|
|
// Budget for exactly two frames; the third add must evict one.
|
|
using var cache = new VideoFrameCache(2L * frameBytes);
|
|
|
|
Assert.True(session.TryReadNextFrame(out var p0));
|
|
cache.Add(p0, session.CurrentFrame, frameBytes);
|
|
Assert.True(session.TryReadNextFrame(out var p1));
|
|
cache.Add(p1, session.CurrentFrame, frameBytes);
|
|
|
|
// Touch p0 so p1 becomes the least-recently-used.
|
|
Assert.True(cache.TryGet(p0, out _));
|
|
|
|
Assert.True(session.TryReadNextFrame(out var p2));
|
|
cache.Add(p2, session.CurrentFrame, frameBytes);
|
|
|
|
Assert.Equal(2, cache.Count);
|
|
Assert.True(cache.TryGet(p0, out _), "p0 was touched and must survive");
|
|
Assert.True(cache.TryGet(p2, out _), "p2 was just added and must survive");
|
|
Assert.False(cache.TryGet(p1, out _), "p1 was least-recently-used and must be evicted");
|
|
Assert.True(cache.ByteCount <= 2L * frameBytes);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetBudget_Shrink_EvictsOnNextAdd()
|
|
{
|
|
using var session = VideoDecoderSession.TryOpen(TestAssets.Video720p, VideoPlaybackOptimization.FastSeeking, out _);
|
|
Assert.NotNull(session);
|
|
var frameBytes = FrameBytes(session!);
|
|
|
|
using var cache = new VideoFrameCache(10L * frameBytes);
|
|
Assert.True(session.TryReadNextFrame(out var p0));
|
|
cache.Add(p0, session.CurrentFrame, frameBytes);
|
|
Assert.True(session.TryReadNextFrame(out var p1));
|
|
cache.Add(p1, session.CurrentFrame, frameBytes);
|
|
Assert.True(session.TryReadNextFrame(out var p2));
|
|
cache.Add(p2, session.CurrentFrame, frameBytes);
|
|
Assert.Equal(3, cache.Count);
|
|
|
|
// Shrink to one frame; eviction is applied lazily on the next add (the worker owns the cache).
|
|
cache.SetBudget(frameBytes);
|
|
Assert.True(session.TryReadNextFrame(out var p3));
|
|
cache.Add(p3, session.CurrentFrame, frameBytes);
|
|
|
|
Assert.Equal(1, cache.Count);
|
|
Assert.True(cache.ByteCount <= frameBytes);
|
|
Assert.True(cache.TryGet(p3, out _), "the just-added frame must survive");
|
|
}
|
|
|
|
private static int FrameBytes(VideoDecoderSession session)
|
|
=> Sdcb.FFmpeg.Raw.ffmpeg.av_image_get_buffer_size(session.PixelFormat, session.Width, session.Height, 1);
|
|
|
|
// Cheap deterministic hash of the luma plane (sparse sample) to prove frame-content identity.
|
|
private static unsafe long LumaChecksum(Frame frame)
|
|
{
|
|
var y = (byte*)frame.Data[0];
|
|
var stride = frame.Linesize[0];
|
|
long sum = 0;
|
|
for (var row = 0; row < frame.Height; row++)
|
|
{
|
|
var line = y + (long)row * stride;
|
|
for (var x = 0; x < frame.Width; x += 7)
|
|
sum += line[x];
|
|
}
|
|
|
|
return sum;
|
|
}
|
|
}
|