diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index 362eeb6..fe64fe3 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -208,6 +208,15 @@ + + + + + + + + diff --git a/Services/VideoClipPipe.cs b/Services/VideoClipPipe.cs index 69bdd3c..f505119 100644 --- a/Services/VideoClipPipe.cs +++ b/Services/VideoClipPipe.cs @@ -226,6 +226,8 @@ public sealed class VideoClipPipe : IDisposable }); var stream = process.StandardOutput; + var passStart = Stopwatch.GetTimestamp(); + var pacer = new FramePacer(); var raw = new byte[65536]; var carry = Array.Empty(); var floatScratch = new float[AudioChunkFloats]; @@ -254,11 +256,14 @@ public sealed class VideoClipPipe : IDisposable { chunks++; AudioAvailable?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels)); - // Pace the NEXT chunk so the clip's audio lands at real-time cadence, - // matching how the video pipe is paced by frame rate. - await _delay( - TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)), ct) - .ConfigureAwait(false); + // 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; } diff --git a/ytLive.Tests/MediaCompositeOutputTests.cs b/ytLive.Tests/MediaCompositeOutputTests.cs new file mode 100644 index 0000000..863fcde --- /dev/null +++ b/ytLive.Tests/MediaCompositeOutputTests.cs @@ -0,0 +1,80 @@ +using System; +using Xunit; +using ytLive.Models; +using ytLive.Services; +using ytLive.Services.Compositor; + +namespace ytLive.Tests; + +/// +/// "Preview looks great" only proves the preview. The recording and the stream go through +/// , which is a different path: the preview pane draws each layer as its +/// own WPF image, while the output blits every layer into one frame. This proves the two things that +/// matter for a KEYED video layer in the output — the keyed (alpha 0) pixels let the layer beneath +/// through, and the opaque part of the clip covers it. +/// +public class MediaCompositeOutputTests +{ + private static VideoFrame Solid(int w, int h, byte r, byte g, byte b, byte a = 255) + { + var pixels = new byte[w * h * 4]; + for (var i = 0; i < pixels.Length; i += 4) + { + pixels[i] = b; pixels[i + 1] = g; pixels[i + 2] = r; pixels[i + 3] = a; + } + return new VideoFrame(w, h, pixels); + } + + [Fact] + public void KeyedVideoLayer_LetsTheBackgroundThrough_AndCoversTheRest() + { + // Background: red. Video layer at (640,340) 640×400: transparent left half (keyed out), + // opaque blue right half (the subject). + var background = Solid(1920, 1080, 255, 0, 0); + var keyedClip = Solid(640, 400, 0, 0, 255, a: 0); + var half = 640 / 2; + for (var y = 0; y < 400; y++) + for (var x = half; x < 640; x++) + { + var i = (y * 640 + x) * 4; + keyedClip.BgraPixels[i + 3] = 255; + } + + var bg = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" }; + var video = new Source { Type = SourceType.MediaSource, MediaPath = "clip.mp4", X = 640, Y = 340, Width = 640, Height = 400 }; + var scene = new Scene { Name = "Live", Elements = { bg, video } }; + + VideoFrame? FrameFor(SceneElement element) => element switch + { + Source { IsBackground: true } => background, + Source { IsMediaSource: true } => keyedClip, + _ => null, + }; + + var options = new CompositorOptions + { + SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080, + OutputWidth = 1920, OutputHeight = 1080, + }; + + var output = new SceneCompositor().Render(scene, FrameFor, null, options); + + // Keyed half: the background must show through — this is what "green screen removed" means + // in the recording, not just in the pane. + AssertColor(output, 700, 400, 255, 0, 0); + AssertColor(output, 950, 600, 255, 0, 0); + + // Subject half: the clip's own pixels cover the background. + AssertColor(output, 1000, 400, 0, 0, 255); + AssertColor(output, 1250, 700, 0, 0, 255); + + // Outside the layer: untouched background. + AssertColor(output, 100, 100, 255, 0, 0); + } + + private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b) + { + var i = (y * frame.Width + x) * 4; + Assert.Equal((b, g, r), (frame.BgraPixels[i], frame.BgraPixels[i + 1], frame.BgraPixels[i + 2])); + } +}