FfmpegEncoder resolved the encoder name into its OWN StreamHealth instance, while the view model read CurrentHealth — a different object. So every recorded session was filed as "unknown", and the rate history's encoder key (evidence about NVENC says nothing about QSV) had nothing to key on. The name now travels on the encoder interface and the pump forwards it: IFfmpegEncoder.VideoEncoderName -> FramePump.EncoderName. Caught while adding it: reading _framePump.EncoderName from RefreshRateProposal throws, because that call runs at MainViewModel ctor line 354 and the pump is built at line 468. Eight tests failed with the same NullReferenceException, which took a while to see because the harness's test output is buffered — the first honest look at per-test logging named it in one line. Guarded; the history falls back to the last session's encoder for that tier, which is exactly the pre-first-session case the fallback already existed for. Tests: FramePumpTests gains the encoder-name assertion (null before start, h264_nvenc after), and the three fake encoders implement the new member. 675 pass, 0 warnings.
613 lines
26 KiB
C#
613 lines
26 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media;
|
|
using Microsoft.Data.Sqlite;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Compositor;
|
|
using ytLive.Services.Encoder;
|
|
using ytLive.ViewModels;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// Good Dog (2026-09-26): the free-tier branding credit must be COMPOSITED INTO THE
|
|
/// BROADCAST, and must be exactly what the creator sees in the preview.
|
|
/// <para>The bug: the credit existed only as a WPF <c>BrandFlashLayer</c> in
|
|
/// PreviewPane.xaml — a 0.25-opacity TextBlock on a 300s timer. A viewer of the
|
|
/// stream or the recording never saw it, so the "free tier shows branding" promise was
|
|
/// simply not being kept. The frame pump has carried an unused <c>flashFrame</c> slot
|
|
/// for exactly this and it was never wired.</para>
|
|
/// <para>The fix routes ONE rendered frame to both consumers: the output compositor and
|
|
/// the preview pane. These facts pin the behaviour the creator specified — in the
|
|
/// recording, 2 seconds, random spot every time, inside the frame, and never for a
|
|
/// paying customer.</para>
|
|
/// </summary>
|
|
[Collection("RealApp")]
|
|
public sealed class BrandFlashOutputTests
|
|
{
|
|
private readonly RealAppHost _app;
|
|
|
|
public BrandFlashOutputTests(RealAppHost app) => _app = app;
|
|
|
|
// ---------- the headline: the credit is in the encoded output ----------
|
|
|
|
[Fact]
|
|
public async Task FreeTier_Credit_ReachesTheEncodedOutputFrame()
|
|
{
|
|
await _app.RunAsync(async () =>
|
|
{
|
|
// A real 1920x1080 pump: the credit is authored at master size, so a
|
|
// toy-sized canvas would clip it away and prove nothing.
|
|
var scene = new Scene { Name = "Live" };
|
|
scene.Elements.Add(new Source
|
|
{
|
|
Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0",
|
|
});
|
|
|
|
using var presenter = new BrandFlashPresenter(new Random(1));
|
|
presenter.Show();
|
|
presenter.Advance(0.5); // land at the top of the full-opacity hold
|
|
var credit = presenter.Frame;
|
|
Assert.NotNull(credit);
|
|
|
|
var encoder = new CapturingEncoder();
|
|
using var pump = new FramePump(
|
|
sceneProvider: () => scene,
|
|
frameResolver: _ => null,
|
|
compositorOptions: () => new CompositorOptions
|
|
{
|
|
SourceRectX = 0, SourceRectY = 0,
|
|
SourceRectWidth = BrandFlashPresenter.MasterWidth,
|
|
SourceRectHeight = BrandFlashPresenter.MasterHeight,
|
|
OutputWidth = BrandFlashPresenter.MasterWidth,
|
|
OutputHeight = BrandFlashPresenter.MasterHeight,
|
|
},
|
|
encoderOptions: () => new EncoderOptions
|
|
{
|
|
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
|
|
Width = BrandFlashPresenter.MasterWidth,
|
|
Height = BrandFlashPresenter.MasterHeight,
|
|
Fps = 30,
|
|
},
|
|
encoderFactory: () => encoder,
|
|
pacingDelay: (_, _) => Task.CompletedTask,
|
|
brandFlash: () => presenter.Frame);
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
|
await pump.StartAsync(cts.Token);
|
|
await encoder.FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
|
await pump.StopAsync();
|
|
|
|
var sent = encoder.Frames[0];
|
|
Assert.True(HasBrightPixels(sent),
|
|
"the branding credit never reached a frame submitted to the encoder — "
|
|
+ "it is still preview-only, so nobody watching the stream sees it");
|
|
|
|
// The preview must show the very same thing, not a separate XAML text block.
|
|
Assert.Equal(BrandFlashPresenter.MasterWidth, sent.Width);
|
|
Assert.Equal(BrandFlashPresenter.MasterHeight, sent.Height);
|
|
});
|
|
}
|
|
|
|
// ---------- a fully static scene must not swallow it ----------
|
|
|
|
[Fact]
|
|
public async Task FullyStaticScene_StillShowsTheCredit()
|
|
{
|
|
await _app.RunAsync(async () =>
|
|
{
|
|
// Regression on a PRE-EXISTING defect found while wiring this up: the
|
|
// fully-static shortcut stretched the cached bake and returned it, dropping
|
|
// every per-frame overlay on the floor — the credit, and (before this
|
|
// change) the social bar and the alert ticker too. A creator with a single
|
|
// static background would have seen the flash preview-only, forever.
|
|
var black = SolidBlackFrame();
|
|
var scene = new Scene { Name = "Live" };
|
|
// One Background element and nothing dynamic => GetSplitPoint ==
|
|
// Elements.Count, which is the branch that used to drop the overlays.
|
|
scene.Elements.Add(new Source { Type = SourceType.Background });
|
|
|
|
using var presenter = new BrandFlashPresenter(new Random(2));
|
|
presenter.Show();
|
|
presenter.Advance(0.5);
|
|
Assert.NotNull(presenter.Frame);
|
|
|
|
var encoder = new CapturingEncoder();
|
|
using var pump = new FramePump(
|
|
sceneProvider: () => scene,
|
|
frameResolver: e => e is Source { Type: SourceType.Background } ? black : null,
|
|
compositorOptions: () => new CompositorOptions
|
|
{
|
|
SourceRectX = 0, SourceRectY = 0,
|
|
SourceRectWidth = BrandFlashPresenter.MasterWidth,
|
|
SourceRectHeight = BrandFlashPresenter.MasterHeight,
|
|
OutputWidth = BrandFlashPresenter.MasterWidth,
|
|
OutputHeight = BrandFlashPresenter.MasterHeight,
|
|
},
|
|
encoderOptions: () => new EncoderOptions
|
|
{
|
|
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
|
|
Width = BrandFlashPresenter.MasterWidth,
|
|
Height = BrandFlashPresenter.MasterHeight,
|
|
Fps = 30,
|
|
},
|
|
encoderFactory: () => encoder,
|
|
pacingDelay: (_, _) => Task.CompletedTask,
|
|
brandFlash: () => presenter.Frame,
|
|
sceneGraph: new SceneGraph());
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
|
await pump.StartAsync(cts.Token);
|
|
await encoder.FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
|
await pump.StopAsync();
|
|
|
|
Assert.True(HasBrightPixels(encoder.Frames[0]),
|
|
"a fully static scene dropped the branding credit — the fully-static path "
|
|
+ "stretches the cached bake and used to discard every per-frame overlay");
|
|
});
|
|
}
|
|
|
|
// ---------- the preview shows the same rendered credit ----------
|
|
|
|
[Fact]
|
|
public void PreviewPane_ShowsTheSameCreditThatGoesToTheStream()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
MainViewModel.ShowBrandFlash = true; // the credit is off by default (2026-10-09)
|
|
var window = new MainWindow();
|
|
var vm = (MainViewModel)window.DataContext;
|
|
try
|
|
{
|
|
window.Show();
|
|
window.UpdateLayout();
|
|
|
|
using var presenter = new BrandFlashPresenter(new Random(3));
|
|
presenter.Show();
|
|
presenter.Advance(0.5);
|
|
var credit = presenter.Frame;
|
|
Assert.NotNull(credit);
|
|
|
|
vm.PublishBrandFlashPreview(credit);
|
|
window.UpdateLayout();
|
|
|
|
var pane = (UserControl)window.FindName("PreviewPane")!;
|
|
var holder = Find(pane, e => e is FrameworkElement f
|
|
&& f.Name == "BrandFlashElement") as FrameworkElement;
|
|
Assert.True(holder != null,
|
|
"PreviewPane.xaml has no BrandFlashElement — the credit is a global "
|
|
+ "overlay, so it needs its own root, not a Source's Image");
|
|
Assert.Equal(Visibility.Visible, holder!.Visibility);
|
|
|
|
var image = Find(holder, e => e is Image) as Image;
|
|
Assert.True(image != null && image.Source != null,
|
|
"the branding element never bound a bitmap, so the credit is never drawn");
|
|
Assert.IsType<System.Windows.Media.Imaging.WriteableBitmap>(image!.Source);
|
|
|
|
// It must go away again rather than sit on the next scene forever.
|
|
vm.PublishBrandFlashPreview(null);
|
|
window.UpdateLayout();
|
|
Assert.Equal(Visibility.Collapsed, holder.Visibility);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
MainViewModel.ShowBrandFlash = false;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------- the specified numbers ----------
|
|
|
|
[Fact]
|
|
public void Credit_LastsTwoSeconds_ThenIsGone()
|
|
{
|
|
// 500ms fade in, 1000ms held, 500ms fade out.
|
|
Assert.Equal(0.0, BrandFlashPresenter.OpacityAt(0.0), 3);
|
|
Assert.Equal(0.5, BrandFlashPresenter.OpacityAt(0.25), 3);
|
|
Assert.Equal(1.0, BrandFlashPresenter.OpacityAt(0.5), 3);
|
|
Assert.Equal(1.0, BrandFlashPresenter.OpacityAt(1.49), 3);
|
|
Assert.Equal(0.5, BrandFlashPresenter.OpacityAt(1.75), 3);
|
|
Assert.Equal(0.0, BrandFlashPresenter.OpacityAt(2.0), 3);
|
|
Assert.Equal(2.0, BrandFlashPresenter.PresentationSeconds, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void Credit_IsGoneExactlyWhenTheTwoSecondsAreUp()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
using var presenter = new BrandFlashPresenter(new Random(4));
|
|
presenter.Show();
|
|
// At exactly t=0 the fade-in has not started, so opacity is 0 and there is
|
|
// deliberately nothing to blit — the credit appears on the next tick.
|
|
Assert.Null(presenter.Frame);
|
|
|
|
presenter.Advance(1.8);
|
|
Assert.NotNull(presenter.Frame); // 1.8s: inside the fade-out
|
|
Assert.True(presenter.IsPresenting);
|
|
|
|
presenter.Advance(0.3);
|
|
Assert.Null(presenter.Frame); // 2.1s in — over
|
|
Assert.False(presenter.IsPresenting);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryPresentation_PicksADifferentSpot_AndAlwaysLandsOnTheCanvas()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
using var presenter = new BrandFlashPresenter(new Random(7));
|
|
var spots = new List<Point>();
|
|
|
|
for (var run = 0; run < 40; run++)
|
|
{
|
|
presenter.Show();
|
|
presenter.Advance(0.5);
|
|
var frame = presenter.Frame;
|
|
Assert.NotNull(frame);
|
|
|
|
// The credit must be FULLY on the canvas — this is the "how far can the
|
|
// left margin travel" limit: 0 .. (canvas - creditWidth), never wrapped.
|
|
var (minX, minY, maxX, maxY) = AlphaBounds(frame!);
|
|
Assert.True(minX >= 0 && minY >= 0,
|
|
$"the credit hangs off the top-left at ({minX},{minY})");
|
|
Assert.True(maxX < frame.Width && maxY < frame.Height,
|
|
$"the credit runs off the bottom-right at ({maxX},{maxY})");
|
|
spots.Add(new Point(minX, minY));
|
|
}
|
|
|
|
Assert.True(spots.Distinct().Count() > spots.Count / 2,
|
|
"the spot barely changed across 40 presentations — it is not random per flash");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Placement_StaysInsideTheFrameForAnyTextSize()
|
|
{
|
|
var rand = new Random(11);
|
|
for (var i = 0; i < 500; i++)
|
|
{
|
|
var w = rand.Next(1, 4000);
|
|
var h = rand.Next(1, 3000);
|
|
var p = BrandFlashPresenter.ComputePlacement(w, h,
|
|
BrandFlashPresenter.MasterWidth, BrandFlashPresenter.MasterHeight, rand);
|
|
Assert.InRange(p.X, 0, Math.Max(0, BrandFlashPresenter.MasterWidth - w));
|
|
Assert.InRange(p.Y, 0, Math.Max(0, BrandFlashPresenter.MasterHeight - h));
|
|
}
|
|
|
|
// A credit wider than the canvas still gets a legal offset rather than throwing.
|
|
var over = BrandFlashPresenter.ComputePlacement(5000, 5000,
|
|
BrandFlashPresenter.MasterWidth, BrandFlashPresenter.MasterHeight, rand);
|
|
Assert.Equal(0, over.X);
|
|
Assert.Equal(0, over.Y);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cadence_RepeatsEveryThirtyToSixtySeconds()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
using var presenter = new BrandFlashPresenter(new Random(5));
|
|
presenter.Start();
|
|
try
|
|
{
|
|
// First credit 5s after go-live, then not again inside 30s.
|
|
Step(presenter, 4.0);
|
|
Assert.Null(presenter.Frame);
|
|
Step(presenter, 1.5);
|
|
Assert.NotNull(presenter.Frame);
|
|
|
|
// Sample the start-to-start intervals the presenter actually schedules
|
|
// rather than trusting the constants. A rising edge is a new credit.
|
|
// The first gap is the deliberate 5s opener, so it is excluded.
|
|
var starts = new List<double>();
|
|
var t = 0.0;
|
|
var wasPresenting = presenter.IsPresenting;
|
|
while (t < 1200.0)
|
|
{
|
|
presenter.Advance(0.05);
|
|
t += 0.05;
|
|
var presenting = presenter.IsPresenting;
|
|
if (!wasPresenting && presenting) starts.Add(t);
|
|
wasPresenting = presenting;
|
|
}
|
|
|
|
var intervals = starts.Zip(starts.Skip(1), (a, b) => b - a)
|
|
.Where(g => g > 6.0) // drops the single 5s opener
|
|
.ToList();
|
|
Assert.True(intervals.Count >= 15,
|
|
$"expected ~20 credits in 1200s of a 30-60s cadence, saw {starts.Count}");
|
|
|
|
// The whole point: no beat a viewer could tune out, and none of the
|
|
// collapsing gaps an absolute-deadline bug produces.
|
|
Assert.All(intervals, g => Assert.InRange(g, 30.0 - 0.2, 60.0 + 0.2));
|
|
Assert.True(intervals.Distinct().Count() > intervals.Count / 2,
|
|
"the cadence barely varied — it is not random per flash");
|
|
}
|
|
finally
|
|
{
|
|
presenter.Stop();
|
|
}
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Credit_IsNeverEmittedForAPayingCustomer()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
using var presenter = new BrandFlashPresenter(new Random(6));
|
|
presenter.Show();
|
|
presenter.Advance(0.5);
|
|
Assert.NotNull(presenter.Frame);
|
|
|
|
// A key entered mid-credit must kill it on the next tick, not let the
|
|
// advertisement finish playing.
|
|
presenter.Enabled = false;
|
|
Assert.Null(presenter.Frame);
|
|
Assert.False(presenter.IsPresenting);
|
|
|
|
presenter.Advance(0.1);
|
|
presenter.Show();
|
|
Assert.Null(presenter.Frame);
|
|
|
|
// Going back to free tier restores it.
|
|
presenter.Enabled = true;
|
|
presenter.Show();
|
|
presenter.Advance(0.5);
|
|
Assert.NotNull(presenter.Frame);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void BrandFlashEnabled_IsDerivedFromTheLicence_AndCannotBeSwitchedOn()
|
|
{
|
|
var prop = typeof(MainViewModel).GetProperty(nameof(MainViewModel.BrandFlashEnabled));
|
|
Assert.NotNull(prop);
|
|
Assert.Null(prop!.SetMethod);
|
|
Assert.True(prop.CanRead, "the preview needs to read the gate state");
|
|
}
|
|
|
|
/// <summary>Creator ruling 2026-10-09: the credit is OFF by default — a freshly
|
|
/// constructed app with no licence still emits nothing to the preview or the output.</summary>
|
|
[Fact]
|
|
public void Credit_IsOffByDefault_EvenOnTheFreeTier()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
Assert.False(MainViewModel.ShowBrandFlash);
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
var window = new MainWindow();
|
|
var vm = (MainViewModel)window.DataContext;
|
|
try
|
|
{
|
|
Assert.False(vm.IsPremium);
|
|
Assert.False(vm.BrandFlashEnabled);
|
|
vm.AdvanceBrandFlash(120); // past several cadence windows
|
|
Assert.Null(vm.BrandFlashFrame());
|
|
Assert.False(vm.BrandFlashVisible);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------- the credit is NOT a go-live-only behaviour ----------
|
|
|
|
/// <summary>Reported 2026-09-26: "the made with llamacasty flash should appear in
|
|
/// all scenes, not just live" and "should also appear in recordings". The presenter
|
|
/// used to be Start/Stop-ed from <c>UpdateLiveVisuals</c>'s IsLive branch, so a
|
|
/// recording made WITHOUT ever going live carried no credit at all.
|
|
/// <para>This drives a real, freshly-constructed ViewModel that has never gone
|
|
/// live, advances past the 5s first-flash delay and asks the frame the frame pump
|
|
/// would composite. A non-null frame here is the whole fix: the cadence is running
|
|
/// for the life of the app, and since go-live and local recording are the SAME
|
|
/// FramePump (both call <c>StartAsync</c> with the same <c>brandFlash:</c> delegate)
|
|
/// the recording carries the credit too.</para></summary>
|
|
[Fact]
|
|
public void Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
MainViewModel.ShowBrandFlash = true; // the credit is off by default (2026-10-09)
|
|
var window = new MainWindow();
|
|
var vm = (MainViewModel)window.DataContext;
|
|
try
|
|
{
|
|
window.Show();
|
|
window.UpdateLayout();
|
|
|
|
// The precondition that used to guarantee an empty credit: offline,
|
|
// not recording, never broadcast.
|
|
Assert.False(vm.IsLive, "this test is about the NOT-live case");
|
|
Assert.False(vm.IsRecording);
|
|
Assert.Equal(StreamStatus.Offline, vm.StreamStatus);
|
|
|
|
// No credit is due yet — the first one lands 5s in.
|
|
Assert.Null(vm.BrandFlashFrame());
|
|
|
|
// Step it in 1/30s increments like the real DispatcherTimer. Do NOT
|
|
// pass 5.1 in one call: Advance both opens the presentation AND ages
|
|
// it by the same delta, so a single 5.1s step would blow clean through
|
|
// the 2s window and the credit would be gone before it was ever read.
|
|
Step(vm, 5.1);
|
|
|
|
var frame = vm.BrandFlashFrame();
|
|
Assert.True(frame != null,
|
|
"no credit with no live session: the cadence is still gated on "
|
|
+ "IsLive, so recordings made without going live carry no branding");
|
|
Assert.Equal(1920, frame!.Width);
|
|
Assert.Equal(1080, frame.Height);
|
|
|
|
// And it reaches the preview pane in this state too, not just the encoder.
|
|
vm.PublishBrandFlashPreview(frame);
|
|
window.UpdateLayout();
|
|
var pane = (UserControl)window.FindName("PreviewPane")!;
|
|
var holder = Find(pane, e => e is FrameworkElement f
|
|
&& f.Name == "BrandFlashElement") as FrameworkElement;
|
|
Assert.Equal(Visibility.Visible, holder!.Visibility);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
MainViewModel.ShowBrandFlash = false;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>The presenter is started ONCE for the life of the app now, so the
|
|
/// premium edge stopping its timer is no longer rescued by the next go-live
|
|
/// calling <c>Start()</c> again: a key entered mid-session used to leave the credit
|
|
/// dead until the process restarted. Re-enabling must resume the cadence.</summary>
|
|
[Fact]
|
|
public void ADowngradeMidSession_RestartsTheCadenceTimer()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
using var presenter = new BrandFlashPresenter(new Random(11));
|
|
presenter.Start();
|
|
Assert.True(presenter.IsCadenceTimerEnabled,
|
|
"Start() must leave the cadence timer running");
|
|
|
|
presenter.Enabled = false; // premium -> the advertisement is cut and the timer stops
|
|
Assert.False(presenter.IsCadenceTimerEnabled);
|
|
|
|
presenter.Enabled = true; // key entered mid-session: no second Start() will come
|
|
Assert.True(presenter.IsCadenceTimerEnabled,
|
|
"the credit stayed dead after a downgrade — the premium edge stopped the "
|
|
+ "timer and nothing restarted it, because Start() is now a one-time "
|
|
+ "app-startup call rather than a per-go-live one");
|
|
});
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
private static DependencyObject? Find(DependencyObject parent, Func<DependencyObject, bool> predicate)
|
|
{
|
|
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
|
|
{
|
|
var child = VisualTreeHelper.GetChild(parent, i);
|
|
if (predicate(child)) return child;
|
|
var found = Find(child, predicate);
|
|
if (found != null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Drive the clock in 1/30s steps, like the real timer does.</summary>
|
|
private static void Step(BrandFlashPresenter presenter, double seconds)
|
|
{
|
|
for (var t = 0.0; t < seconds; t += 1.0 / 30.0) presenter.Advance(1.0 / 30.0);
|
|
}
|
|
|
|
/// <summary>Same stepping, against the ViewModel's own presenter.</summary>
|
|
private static void Step(MainViewModel vm, double seconds)
|
|
{
|
|
for (var t = 0.0; t < seconds; t += 1.0 / 30.0) vm.AdvanceBrandFlash(1.0 / 30.0);
|
|
}
|
|
|
|
/// <summary>Alpha bounding box of everything the frame actually drew.</summary>
|
|
private static (int MinX, int MinY, int MaxX, int MaxY) AlphaBounds(VideoFrame frame)
|
|
{
|
|
var minX = int.MaxValue;
|
|
var minY = int.MaxValue;
|
|
var maxX = -1;
|
|
var maxY = -1;
|
|
for (var y = 0; y < frame.Height; y++)
|
|
for (var x = 0; x < frame.Width; x++)
|
|
{
|
|
if (frame.BgraPixels[(y * frame.Stride) + (x * 4) + 3] == 0) continue;
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
}
|
|
Assert.True(maxX >= 0, "the credit frame drew no visible pixels at all");
|
|
return (minX, minY, maxX, maxY);
|
|
}
|
|
|
|
/// <summary>True when the frame carries near-white opaque pixels — the credit's core.
|
|
/// A black background cannot produce them, so this cannot false-positive.</summary>
|
|
private static bool HasBrightPixels(VideoFrame frame)
|
|
{
|
|
for (var i = 0; i < frame.BgraPixels.Length; i += 4)
|
|
{
|
|
if (frame.BgraPixels[i + 3] < 200) continue;
|
|
if (frame.BgraPixels[i] > 200 && frame.BgraPixels[i + 1] > 200 && frame.BgraPixels[i + 2] > 200)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>An opaque black master frame. The credit's core is near-white, so it
|
|
/// cannot false-positive against this and the assertion stays honest.</summary>
|
|
private static VideoFrame SolidBlackFrame()
|
|
{
|
|
var w = BrandFlashPresenter.MasterWidth;
|
|
var h = BrandFlashPresenter.MasterHeight;
|
|
var pixels = new byte[w * h * 4];
|
|
for (var i = 0; i < pixels.Length; i += 4) pixels[i + 3] = 255;
|
|
return new VideoFrame(w, h, pixels) { IsOpaque = true };
|
|
}
|
|
|
|
/// <summary>Captures the frames the pump actually submits — the same trick the
|
|
/// production encoder uses, so the assertion is about the bytes on the wire.</summary>
|
|
private sealed class CapturingEncoder : IFfmpegEncoder
|
|
{
|
|
public string? VideoEncoderName => "h264_nvenc";
|
|
|
|
public readonly List<VideoFrame> Frames = new();
|
|
public readonly TaskCompletionSource FirstFrame =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public int DroppedFrames { get; set; }
|
|
|
|
public event EventHandler<StreamHealth>? HealthUpdated;
|
|
public event EventHandler<string>? ProcessFailed;
|
|
|
|
public Task StartAsync(EncoderOptions options, CancellationToken ct = default)
|
|
=> Task.CompletedTask;
|
|
|
|
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken ct = default)
|
|
{
|
|
Frames.Add(new VideoFrame(frame.Width, frame.Height, (byte[])frame.BgraPixels.Clone()));
|
|
FirstFrame.TrySetResult();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StopAsync(CancellationToken ct = default) => Task.CompletedTask;
|
|
public void Dispose() { }
|
|
|
|
// Never invoked here; present so the fake satisfies the interface the same way
|
|
// FramePumpTests' fake does.
|
|
public void RaiseFailed(string message) => ProcessFailed?.Invoke(this, message);
|
|
public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
|
}
|
|
}
|