9761b1d4be
Creator 2026-09-26: "the made with llamacasty flash should appear in all scenes,
not just live" and "should also appear in recordings". The presenter was
Start()/Stop()-ed from UpdateLiveVisuals()'s IsLive branch, so a recording made
WITHOUT ever going live carried no credit at all -- the exact case the creator hit
while judging compositing from local recordings.
It is now Start()ed once in the MainViewModel ctor and never stopped on live-state
churn. One start covers every scene, the preview, the stream and the recording,
because go-live and local recording are the SAME FramePump: both
Streaming.Operations.cs:68 and :199 call StartAsync with the same brandFlash:
delegate. Verified by reading both call sites, not assumed -- there is no second
encoder path that needed a "redirect". The licence gate needs no live branch at
all: IsPremium's setter already pushes BrandFlashPresenter.Enabled from anywhere.
Fixed a trap that app-lifetime exposed: Enabled = false stops the presenter's
DispatcherTimer to cut the advertisement mid-credit. When Start() was per-go-live
the next go-live restarted it; with a single app-lifetime Start() nothing would,
so a key entered mid-session would leave the credit dead until the process was
restarted. The setter now restarts the timer when re-enabling while _running.
Tests (12 facts in BrandFlashOutputTests, +2):
Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt -- drives a real
never-live VM past the 5s first-flash delay and asks the frame the pump would
composite. Uses a new internal AdvanceBrandFlash seam; because Advance only
advances the cadence while the presenter is running, a credit coming out
proves Start() happened at construction.
ADowngradeMidSession_RestartsTheCadenceTimer -- asserts the premium/downgrade
edge decision directly (IsCadenceTimerEnabled) instead of sleeping through a
30-60s interval, which a synchronous test body cannot observe.
Full suite 364/364.
577 lines
24 KiB
C#
577 lines
24 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;
|
|
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;
|
|
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");
|
|
}
|
|
|
|
// ---------- 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;
|
|
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;
|
|
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 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);
|
|
}
|
|
}
|