Files
LlamaCasty/ytLive.Tests/ChromaKeyRealClipTests.cs
T
gramps 5577aaaf91 fix(media): play at the clip's real rate — the pipe was copying a frame per read
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.
2026-11-05 09:57:07 -08:00

117 lines
5.3 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.
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);
}
}