Reported as "the default video and the green screen video are playing at a hideously slow rate… I thought the playback timing issues were resolved?!" They were not: two independent faults, both in the shared clip pipe. 1. QUADRATIC COPY IN THE FRAME READER. `RawVideoFrameReader` re-concatenated the whole pending buffer on every `Feed`, and the pipe feeds it 64 KB at a time. A 1080p BGRA frame is 8 MB, so one frame cost ~127 copies of a growing buffer — roughly half a gigabyte of memcpy per frame. Measured on a 30 fps clip: 6 frames in 3 seconds (2 fps). The reader now fills one preallocated frame buffer and hands that array to the VideoFrame, so a frame is copied exactly once. Pipe reads are 256 KB. Same clip: 29 fps. 2. PACING SLEPT ON TOP OF THE WORK. A fixed 1/fps sleep after each frame adds the frame's own cost, so 20 ms of work on a 30 fps clip yields 1/(33+20) ≈ 19 fps — measured 20.9. Pacing is now deadline-based (`Helpers/FramePacer.cs`): wait only what is left of the frame's interval, resync when a frame overruns instead of bursting, never sleep longer than one interval. The alert clip shares this pipe, so it gained both fixes. Tests: `FramePacerTests` (subtract work, no drift, late → resync, zero duration, reset); `ChromaKeyRealClipTests.RealClip_PlaysAtItsNativeRate` (throughput floor of 15 fps on the real 30 fps clip — the old code sat at 2); the two pacing assertions now check "at most one interval" rather than an exact sleep, which is what deadline pacing means. Full suite: 574 passed, 0 failed, clean build.
117 lines
5.3 KiB
C#
117 lines
5.3 KiB
C#
using System;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Xunit;
|
||
using ytLive.Services;
|
||
using ytLive.Services.Encoder;
|
||
|
||
namespace ytLive.Tests;
|
||
|
||
/// <summary>
|
||
/// TASK 64 against a REAL clip (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080, 5 s, a
|
||
/// subject moving over a green field). The detector had only ever seen synthetic frames; this runs
|
||
/// the bundled ffmpeg, our probe, our detector, and our keyed decode on actual video.
|
||
/// <para>It also pins the decision that the key colour comes from the measured screen rather than a
|
||
/// canonical green — on this clip the field is #00FE00 and a hardcoded #00B140 keys <b>nothing</b>
|
||
/// (measured: 0.0% of pixels transparent), while the detected colour keys 97.8%.</para>
|
||
/// </summary>
|
||
public class ChromaKeyRealClipTests
|
||
{
|
||
private const string ClipName = "test_greenscreen_moving_subject.mp4";
|
||
|
||
private static string ClipPath => Path.Combine(AppContext.BaseDirectory, "Fixtures", ClipName);
|
||
|
||
private static string FfmpegPath => Path.Combine(AppContext.BaseDirectory, "ffmpeg", "ffmpeg.exe");
|
||
|
||
[Fact]
|
||
public async Task RealGreenScreenClip_IsDetected_AndKeysWithTheMeasuredColour()
|
||
{
|
||
Assert.True(File.Exists(ClipPath), $"missing fixture: {ClipPath}");
|
||
Assert.True(File.Exists(FfmpegPath), $"the bundled ffmpeg should sit beside the test binary: {FfmpegPath}");
|
||
|
||
// 1. The probe reads one real frame of the clip.
|
||
var probe = new FfmpegChromaProbe(new FixedLocator(FfmpegPath), () => new FfmpegDecodeProcess());
|
||
var frame = await probe.ProbeAsync(ClipPath);
|
||
Assert.NotNull(frame);
|
||
|
||
// 2. The detector says "green screen", with high confidence.
|
||
var suggestion = ChromaKeyDetector.Detect(frame!);
|
||
Assert.Equal(ChromaScreen.Green, suggestion.Screen);
|
||
Assert.True(suggestion.IsHighConfidence,
|
||
$"a clean green field should be high confidence, got {suggestion.Confidence:0.00}");
|
||
Assert.NotNull(suggestion.ColorHex);
|
||
|
||
// …and the colour it names is the field as shot: green-dominant, almost no red or blue.
|
||
var (r, g, b) = ParseHex(suggestion.ColorHex!);
|
||
Assert.True(g > 0xE0 && r < 0x20 && b < 0x20,
|
||
$"the detected colour should be the measured field, got {suggestion.ColorHex}");
|
||
|
||
// 3. That colour actually keys the clip through our own pipe: most pixels go transparent,
|
||
// the moving subject survives.
|
||
var keyed = ChromaKeyOptions.For(suggestion);
|
||
var firstFrame = new TaskCompletionSource<VideoFrame>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
using var pipe = new VideoClipPipe(
|
||
ClipPath, 320, 180, new FixedLocator(FfmpegPath), () => new FfmpegDecodeProcess(),
|
||
audio: false, chromaKey: keyed);
|
||
pipe.FrameAvailable += f => firstFrame.TrySetResult(f);
|
||
|
||
await pipe.StartAsync();
|
||
var keyedFrame = await firstFrame.Task.WaitAsync(TimeSpan.FromSeconds(30));
|
||
await pipe.StopAsync();
|
||
|
||
var transparent = 0;
|
||
for (var i = 3; i < keyedFrame.BgraPixels.Length; i += 4)
|
||
if (keyedFrame.BgraPixels[i] == 0) transparent++;
|
||
|
||
var ratio = (double)transparent / (keyedFrame.Width * keyedFrame.Height);
|
||
Assert.True(ratio > 0.5,
|
||
$"the keyed decode left only {ratio:P1} transparent — the filter chain is not keying this clip");
|
||
Assert.True(ratio < 1.0, "everything vanished; the subject should survive the key");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Throughput guard (2026-11-05): the pipe used to copy a growing pending buffer on every 64 KB
|
||
/// read, so a 1080p frame cost ~0.5 GB of copying and playback pinned at ~2 fps whatever the
|
||
/// pacing said. A 30 fps clip must here deliver at least 15 fps (measured 29 in a Debug build) —
|
||
/// a floor wide enough for slow hardware and far above the 2 fps regression.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task RealClip_PlaysAtItsNativeRate()
|
||
{
|
||
Assert.True(File.Exists(ClipPath), $"missing fixture: {ClipPath}");
|
||
|
||
var locator = new FixedLocator(FfmpegPath);
|
||
var probe = new FfmpegFrameRateProbe(locator, () => new FfmpegDecodeProcess());
|
||
var frames = 0;
|
||
using var pipe = new VideoClipPipe(
|
||
ClipPath, 1920, 1080, locator, () => new FfmpegDecodeProcess(),
|
||
frameRateProbe: probe, audio: false);
|
||
pipe.FrameAvailable += _ => Interlocked.Increment(ref frames);
|
||
|
||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||
await pipe.StartAsync();
|
||
await Task.Delay(2000);
|
||
await pipe.StopAsync();
|
||
clock.Stop();
|
||
|
||
var fps = frames / clock.Elapsed.TotalSeconds;
|
||
Assert.True(fps >= 15, $"playback ran at {fps:0.0} fps — frames are being lost in the pipe again");
|
||
}
|
||
|
||
private static (int R, int G, int B) ParseHex(string hex)
|
||
{
|
||
var value = hex.TrimStart('#');
|
||
return (Convert.ToInt32(value[..2], 16), Convert.ToInt32(value.Substring(2, 2), 16),
|
||
Convert.ToInt32(value.Substring(4, 2), 16));
|
||
}
|
||
|
||
private sealed class FixedLocator : IFfmpegLocator
|
||
{
|
||
private readonly string _path;
|
||
public FixedLocator(string path) => _path = path;
|
||
public Task<string> LocateAsync(CancellationToken ct = default) => Task.FromResult(_path);
|
||
}
|
||
}
|