Files
gramps 3a56807ee3 fix(media): pace clip audio to a deadline too, and prove the keyed layer in the OUTPUT
Two follow-ups from the creator's lip-sync observation.

1. Audio still slept a fixed 50 ms per chunk — the same "sleep on top of the work" fault fixed for
   video an hour ago, left in place. Every chunk's read cost was added, so a clip's audio fell
   progressively behind its picture: small per chunk, unbounded over a long clip. It now uses the
   same deadline pacer, so audio and video advance on the same wall clock.

2. The preview pane is NOT the compositor. "Preview looks great" proves the decode and the preview
   bitmap; the recording and stream are blitted by SceneCompositor. `MediaCompositeOutputTests`
   renders a keyed video layer over a background through the real compositor and asserts the two
   things that matter: keyed (alpha 0) pixels let the background through, and the subject half
   covers it. So "green screen removed" is now proven in the output path, not just the pane.

Also: a round-clipped video layer had no preview trigger (only webcam+Round existed), so it would
render in the output and be invisible in the pane. Same unit-circle path now covers it.

Tests: 16 scoped (pacer, real-clip rate + keying, composite output, decode pacing, alert pacing)
green. FULL SUITE NOT RUN — the machine is in use: two runs under load aborted in the WPF test host
(HWND subclass crash) and one hit the 15-minute cap. Re-run scripts/verify.sh in a quiet window.
2026-11-05 10:40:25 -08:00

280 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ytLive.Helpers;
using ytLive.Services.Audio;
using ytLive.Services.Encoder;
namespace ytLive.Services;
/// <summary>
/// One video FILE played through ffmpeg — the shared pipe behind every clip, so a media
/// layer (TASK 21) and an alert clip (TASK 47) decode identically. Owns two subprocesses
/// on the same file: video (<c>rawvideo bgra</c>, scaled to the target rect) and audio
/// (<c>f32le -ac 2 -ar 48000</c>, matching the mixer's native format so no resampler is
/// needed). Both are paced to real time, so the picture and the sound stay together even
/// though they arrive on separate pipes.
/// <para><b>Looping is the media case</b>: on natural EOF the whole pass restarts — a fresh
/// pair of processes, because a <see cref="Process"/> cannot be re-started. An alert clip
/// never loops, so its wrapper simply never sets <see cref="Looping"/>.</para>
/// <para>The seams (locator, process factory, frame-rate probe, delay) exist so tests drive
/// a fake decoder with no ffmpeg, no codec, and no audio device.</para>
/// </summary>
public sealed class VideoClipPipe : IDisposable
{
/// <summary>Pipe read size. A 1080p BGRA frame is ~8 MB, so 64 KB reads meant 127 awaits and
/// 127 reader calls per frame; 256 KB keeps the syscall count sane without a large allocation.</summary>
private const int ReadBufferBytes = 256 * 1024;
public const int AudioSampleRate = 48000;
public const int AudioChannels = 2;
private const int AudioChunkFloats = 4800; // 50 ms of stereo
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioAvailable;
public event Action? Completed;
/// <summary>Restart the pass on natural EOF (media/BRB loops).</summary>
public bool Looping { get; set; }
private readonly string _path;
private readonly int _width;
private readonly int _height;
private readonly IFfmpegLocator _locator;
private readonly Func<IDecodeProcess> _processFactory;
private readonly IFrameRateProbe? _probe;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly bool _audio;
private readonly ChromaKeyOptions? _key;
private readonly FramePacer _pacer = new();
private readonly CancellationTokenSource _cts = new();
private bool _running;
private IDecodeProcess? _activeVideo;
private IDecodeProcess? _activeAudio;
public VideoClipPipe(
string path,
int width,
int height,
IFfmpegLocator locator,
Func<IDecodeProcess> processFactory,
IFrameRateProbe? frameRateProbe = null,
Func<TimeSpan, CancellationToken, Task>? delay = null,
bool audio = true,
ChromaKeyOptions? chromaKey = null)
{
_path = path;
_width = width;
_height = height;
_locator = locator;
_processFactory = processFactory;
_probe = frameRateProbe;
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
_audio = audio;
_key = chromaKey;
}
/// <summary>Runs one pass (or a loop of passes). A pass that ended naturally leaves the pipe
/// restartable — the creator can ask a finished clip to play again, which is what the Loop
/// switch and the "Play again" button need.</summary>
public Task StartAsync(CancellationToken cancellationToken = default)
{
if (_running) return Task.CompletedTask;
_running = true;
_ = Task.Run(RunAsync, CancellationToken.None);
return Task.CompletedTask;
}
public Task StopAsync()
{
_cts.Cancel();
Kill(_activeVideo);
Kill(_activeAudio);
_running = false;
return Task.CompletedTask;
}
public void Dispose()
{
_cts.Cancel();
_running = false;
_cts.Dispose();
_activeVideo?.Dispose();
_activeAudio?.Dispose();
}
private void StopInternal()
{
_running = false;
Kill(_activeVideo);
Kill(_activeAudio);
}
private async Task RunAsync()
{
var ct = _cts.Token;
var frames = 0;
var chunks = 0;
try
{
double? frameRate = null;
if (_probe != null)
frameRate = await _probe.ProbeAsync(_path, ct).ConfigureAwait(false);
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
var ffmpegPath = await _locator.LocateAsync(ct).ConfigureAwait(false);
do
{
// Both pipes per pass, awaited together: a restart must begin a new
// video AND a new audio stream, or the picture drifts from the sound
// after the first loop.
var video = RunVideoAsync(ffmpegPath, frameDuration, ct);
var audio = _audio ? RunAudioAsync(ffmpegPath, ct) : Task.FromResult(0);
frames += await video.ConfigureAwait(false);
chunks += await audio.ConfigureAwait(false);
}
while (Looping && !ct.IsCancellationRequested);
}
catch (OperationCanceledException)
{
_running = false;
return;
}
catch (Exception ex)
{
// A failed decode is not fatal: the alert layer falls back to its animations,
// and a media layer just holds its last frame.
AppLog.Write($"Video clip decode failed ({Path.GetFileName(_path)}): {ex.Message}");
_running = false;
return;
}
_running = false;
if (!ct.IsCancellationRequested)
{
AppLog.Write($"Video clip end: {Path.GetFileName(_path)} frames={frames} audioChunks={chunks}");
Completed?.Invoke();
}
}
private async Task<int> RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
{
var passStart = Stopwatch.GetTimestamp();
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f rawvideo -pix_fmt bgra -vf {VideoFilterChain.BuildVideo(_width, _height, _key)} -an -";
using var process = _processFactory();
_activeVideo = process;
process.Start(new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
});
var reader = new RawVideoFrameReader(_width, _height);
var buffer = new byte[ReadBufferBytes];
_pacer.Reset();
var stream = process.StandardOutput;
var frames = 0;
while (!ct.IsCancellationRequested && !process.HasExited)
{
var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
if (read <= 0) break;
var chunk = new byte[read];
Buffer.BlockCopy(buffer, 0, chunk, 0, read);
foreach (var frame in reader.Feed(chunk))
{
frames++;
FrameAvailable?.Invoke(frame);
if (frameDuration > TimeSpan.Zero)
{
// Deadline pacing: sleep what is LEFT of this frame's interval, so the frame's
// own work is not added on top (see FramePacer).
var wait = _pacer.DelayFor(frameDuration, Stopwatch.GetElapsedTime(passStart));
if (wait > TimeSpan.Zero)
await _delay(wait, ct).ConfigureAwait(false);
}
}
}
_activeVideo = null;
return frames;
}
private async Task<int> RunAudioAsync(string ffmpegPath, CancellationToken ct)
{
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f f32le -ac {AudioChannels} -ar {AudioSampleRate} -vn -";
using var process = _processFactory();
_activeAudio = process;
process.Start(new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
});
var stream = process.StandardOutput;
var passStart = Stopwatch.GetTimestamp();
var pacer = new FramePacer();
var raw = new byte[65536];
var carry = Array.Empty<byte>();
var floatScratch = new float[AudioChunkFloats];
var floatIndex = 0;
var chunks = 0;
// The pipe is byte-delimited only: a read may split a float, so leftover
// bytes are carried into the next read instead of dropped (a dropped
// half-float would desync the entire stream).
while (!ct.IsCancellationRequested && !process.HasExited)
{
var read = await stream.ReadAsync(raw, 0, raw.Length, ct).ConfigureAwait(false);
if (read <= 0) break;
var total = carry.Length + read;
var bytes = total <= raw.Length ? raw : new byte[total];
if (carry.Length > 0)
{
Buffer.BlockCopy(carry, 0, bytes, 0, carry.Length);
Buffer.BlockCopy(raw, 0, bytes, carry.Length, read);
}
var floatsTo = total - (total % 4);
for (var i = 0; i < floatsTo && floatIndex < floatScratch.Length; i += 4)
floatScratch[floatIndex++] = BitConverter.ToSingle(bytes, i);
carry = total > floatsTo ? bytes[floatsTo..total] : Array.Empty<byte>();
while (floatIndex == floatScratch.Length)
{
chunks++;
AudioAvailable?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels));
// Deadline pacing, exactly like the video pipe: sleep only what is LEFT of this
// chunk's 50 ms. A fixed sleep added the read cost every chunk, so audio fell
// progressively behind the picture on a long clip (2026-11-05).
var wait = pacer.DelayFor(
TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)),
Stopwatch.GetElapsedTime(passStart));
if (wait > TimeSpan.Zero)
await _delay(wait, ct).ConfigureAwait(false);
floatScratch = new float[AudioChunkFloats];
floatIndex = 0;
}
}
_activeAudio = null;
return chunks;
}
private static void Kill(IDecodeProcess? process)
{
try { process?.Kill(); } catch { /* already exited */ }
}
}