aea0670723
The 12:35 live session proved the decoder healthy (frames=240 audioChunks=156 failed=False both clips, alert audio in the mix at peakMix 0.277→0.733) yet the creator still saw 'no video plays / you lost the video'. Root cause: pacing, not decode. AlertClipDecoderFor built the decoder with no frameRateProbe, so RunAsync computed frameDuration = TimeSpan.Zero and RunVideoAsync's pace step was dead code — all 240 frames of the 10s clip dumped through the pipe in the first ~1-2s (130MB as fast as ffmpeg read), then the box froze on the LAST frame while audio chunk-paced its real-time ~7.8s. Reads exactly like a dead decoder on screen. The media path already wired this seam (MainViewModel.cs:308); the alert factory never supplied one. Derivative fix (media/decoder plumbing, cited in broad consensus of players): pass FfmpegFrameRateProbe into the per-play alert decoder exactly like MediaVideoSource does. Good Dog: AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate — real AlertClipDecoder via the fake-process/fake-probe/delay-recorder seam; red on the old factory (zero pacing delays), green with the probe (one ~10ms delay per frame at 100fps). Full suite 321/322, one known-env flake (RealMouseDrag reorder). Docs in this commit: task-47 addendum, MyMistakes.md (a decoder that drops data faster than wall-clock looks identical to a dead one), HANDOFF rewrite.
79 lines
3.1 KiB
C#
79 lines
3.1 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Xunit;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Encoder;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 47 Good Dog: the alert clip decoder paces VIDEO to real time via the
|
|
/// frame-rate probe. The 2026-09-26 live failure was a healthy decoder whose
|
|
/// frames all dumped in the first ~1-2s (no probe = no pacing), leaving the box
|
|
/// frozen on the last frame while the audio ran its ~8s — read as "no video
|
|
/// plays". This drives the REAL <see cref="AlertClipDecoder"/> against a fake
|
|
/// process + probe and records the pacing delay it requests per frame (the same
|
|
/// seam the media path's decoder test uses — the repo never runs a real codec).
|
|
/// </summary>
|
|
public class AlertClipDecoderTests
|
|
{
|
|
[Fact]
|
|
public async Task AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate()
|
|
{
|
|
const int w = 1, h = 1; // frame bytes = 4
|
|
var payload = new byte[12]; // three frames
|
|
var delays = new List<TimeSpan>();
|
|
|
|
using var decoder = new AlertClipDecoder(
|
|
"clip.mp4", w, h,
|
|
new FakeLocator(),
|
|
() => new FakeDecodeProcess(payload),
|
|
frameRateProbe: new FakeFrameRateProbe(100.0),
|
|
delay: (t, _) => { delays.Add(t); return Task.CompletedTask; });
|
|
|
|
var forwarded = 0;
|
|
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
decoder.FrameAvailable += _ => forwarded++;
|
|
decoder.Completed += () => completed.TrySetResult(true);
|
|
|
|
decoder.Start();
|
|
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Equal(3, forwarded);
|
|
// One pacing delay per emitted frame, each = 1/100s (the probed rate).
|
|
Assert.Equal(forwarded, delays.Count);
|
|
foreach (var d in delays)
|
|
Assert.True(Math.Abs((d - TimeSpan.FromMilliseconds(10)).TotalMilliseconds) < 0.001,
|
|
$"expected ~10ms, got {d.TotalMilliseconds}ms");
|
|
}
|
|
|
|
private sealed class FakeDecodeProcess : IDecodeProcess
|
|
{
|
|
private readonly MemoryStream _stream;
|
|
public FakeDecodeProcess(byte[] bytes) => _stream = new MemoryStream(bytes);
|
|
public void Start(ProcessStartInfo startInfo) { }
|
|
public Stream StandardOutput => _stream;
|
|
public bool HasExited => _stream.Position >= _stream.Length;
|
|
public int ExitCode => 0;
|
|
public void Kill() { }
|
|
public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask;
|
|
public void Dispose() => _stream.Dispose();
|
|
}
|
|
|
|
private sealed class FakeFrameRateProbe : IFrameRateProbe
|
|
{
|
|
private readonly double _fps;
|
|
public FakeFrameRateProbe(double fps) => _fps = fps;
|
|
public Task<double?> ProbeAsync(string path, CancellationToken ct = default)
|
|
=> Task.FromResult<double?>(_fps);
|
|
}
|
|
|
|
private sealed class FakeLocator : IFfmpegLocator
|
|
{
|
|
public Task<string> LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
|
|
}
|
|
} |