TASK 47: draw the alert clip — the preview Image was Collapsed for AlertBox
Four builds (89/90 + two) burned proving the alert video was PERFECT: real h264 1280x720, 240 frames decoded, alert audio in the live mix, and the shipped asset byte-identical (md5 0ee1f496…) to the creator's llamacasty-dancingLlama-thankyou.mp4 with every sampled frame full bright content. Build 90's new diagnostics then showed the frame reaching BOTH consumers every second for the whole clip — `Alert preview: frame=680x200 a255` and `Output resolver: frame 680x200 playing=True` — an opaque, correctly-sized frame, handed over and never seen. Root cause: Controls/PreviewPane.xaml keeps the per-element Image Collapsed unless a DataTrigger fires, and there was no IsAlertBox trigger (only IsImageSource / IsChatBox / IsWebSource / IsWebcam). The alert box's Image was therefore Collapsed forever. Chat boxes rendered because they HAVE a trigger — that asymmetry is the whole clue, and it is why removing/re-adding the layer could never fix it. Fix: an IsAlertBox DataTrigger beside the IsChatBox one. Two Good Dog tests, because the existing fakes had been hiding this: - AlertBoxPreviewVisibilityTests (red `Expected: Visible / Actual: Collapsed`, now green) drives the real MainWindow + PreviewPane and asserts the bound Image is Visible — the first alert test that crosses the XAML at all. - AlertClipOutputTests is the first test in the repo to run a REAL codec: pinned ffmpeg generates a clip, the real AlertClipDecoder + AlertOverlayLayer + SceneCompositor composite it, and the box rect must be a colourful picture that DIFFERS from idle. It passed while the feature was broken in the app — which is exactly why it was needed: it exonerated decode+layer+compositor and pointed the hunt at the last hop. Also keeps the two once-per-second alert diagnostics (resolver + preview) that settled it, pending the creator's call on whether to keep them. Notes: the pinned BtbN ffmpeg has no libx264, so the test clip is -c:v mpeg4. SceneCompositor.Render fills its base with opaque black, so the test compares against an idle render rather than counting non-zero bytes. MediaSource has the same missing trigger in PreviewPane.xaml — left unfixed as out of scope, noted in HANDOFF. Full suite 324/324. Lesson recorded in MyMistakes.md rule (5): a producer that hands over a correct frame has still delivered nothing — when output arrives and the user still sees nothing, stop auditing the producer and audit the consumer's VISIBILITY.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 47 Good Dog (take three, 2026-09-26): the alert box's frame must be
|
||||
/// DRAWN by the preview pane. The 14:26 and 14:28 live runs on build 90 proved
|
||||
/// everything up to the last hop: <c>Alert preview: frame=680x200 a255</c> and
|
||||
/// <c>Output resolver: frame 680x200 playing=True</c> every second for the whole
|
||||
/// clip — a real, opaque, correctly-sized frame (the shipped llama asset decodes
|
||||
/// bright, 1280x720 h264) handed to the canvas — and the box stayed blank.
|
||||
/// Root cause: the preview's per-element <c>Image</c> only becomes Visible for
|
||||
/// IsImageSource / IsChatBox / IsWebSource / IsWebcam, so an AlertBox's Image
|
||||
/// stayed Collapsed and the bound frame was never rendered.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class AlertBoxPreviewVisibilityTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public AlertBoxPreviewVisibilityTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void AlertBox_HandedAFrame_IsDrawnInThePreviewPane()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private void 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();
|
||||
|
||||
var box = new Source
|
||||
{
|
||||
Type = SourceType.AlertBox,
|
||||
Name = "Stream Alerts",
|
||||
X = 100,
|
||||
Y = 100,
|
||||
Width = 320,
|
||||
Height = 180,
|
||||
};
|
||||
vm.StagedScene!.Elements.Add(box);
|
||||
vm.SelectedElement = box;
|
||||
|
||||
// Exactly what the layer writes onto the source every tick while a clip
|
||||
// plays: a real, opaque, box-sized frame.
|
||||
box.VideoImageSource = SolidFrame(320, 180);
|
||||
window.UpdateLayout();
|
||||
|
||||
var pane = (UserControl)window.FindName("PreviewPane")!;
|
||||
var image = FindImageShowing(pane, box.VideoImageSource);
|
||||
Assert.True(image != null,
|
||||
"the preview pane never bound the alert box's frame to an Image");
|
||||
Assert.Equal(Visibility.Visible, image!.Visibility);
|
||||
Assert.True(image.IsVisible,
|
||||
"the alert box's Image is Collapsed, so the frame is never rendered");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static WriteableBitmap SolidFrame(int w, int h)
|
||||
{
|
||||
var bitmap = new WriteableBitmap(w, h, 96, 96, PixelFormats.Bgra32, null);
|
||||
var pixels = new byte[w * h * 4];
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
pixels[i] = 40; // B
|
||||
pixels[i + 1] = 90; // G
|
||||
pixels[i + 2] = 200; // R
|
||||
pixels[i + 3] = 255; // opaque
|
||||
}
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, w, h), pixels, w * 4, 0);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static Image? FindImageShowing(DependencyObject parent, ImageSource? source)
|
||||
=> Walk(parent, e => e is Image i && ReferenceEquals(i.Source, source)) as Image;
|
||||
|
||||
private static DependencyObject? Walk(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 = Walk(child, predicate);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 47 Good Dog (take two, 2026-09-26): a decoded alert clip must actually
|
||||
/// reach the OUTPUT canvas. Three live sessions on build 89 all logged a healthy
|
||||
/// decode — <c>Alert clip start</c> … <c>frames=240 audioChunks=156 failed=False</c>,
|
||||
/// alert audio in the live mix — and still painted NOTHING in the box. The layer
|
||||
/// tests pass because they drive a FAKE decoder straight into <c>RenderFrame</c>:
|
||||
/// nothing ever proves the decoded frame survives the trip through the real
|
||||
/// compositor. This is that proof — a REAL ffmpeg clip, the REAL
|
||||
/// <see cref="AlertClipDecoder"/>, the REAL <see cref="AlertOverlayLayer"/> and the
|
||||
/// REAL <see cref="SceneCompositor"/>, end to end. It is the one test that runs a
|
||||
/// real codec, and deliberately so: the gap it closes is exactly the one a fake
|
||||
/// cannot see.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class AlertClipOutputTests
|
||||
{
|
||||
private const int BoxW = 320;
|
||||
private const int BoxH = 180;
|
||||
private const int BoxX = 100;
|
||||
private const int BoxY = 100;
|
||||
private const int MasterW = 1920;
|
||||
private const int MasterH = 1080;
|
||||
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public AlertClipOutputTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void AlertClip_RealDecode_PaintsPixelsInsideTheAlertBoxRect()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var locator = new FfmpegLocator();
|
||||
var ffmpeg = locator.LocateAsync().GetAwaiter().GetResult();
|
||||
var clip = Path.Combine(Path.GetTempPath(), $"ytLive-test-alert-{Guid.NewGuid():N}.mp4");
|
||||
try
|
||||
{
|
||||
MakeClip(ffmpeg, clip);
|
||||
|
||||
var box = new Source
|
||||
{
|
||||
Type = SourceType.AlertBox,
|
||||
Name = "Stream Alerts",
|
||||
X = BoxX,
|
||||
Y = BoxY,
|
||||
Width = BoxW,
|
||||
Height = BoxH,
|
||||
AlertVideoPath = clip,
|
||||
AlertUseDefaultVideo = false,
|
||||
};
|
||||
var scene = new Scene { Name = "Live", Elements = { box } };
|
||||
|
||||
using var layer = new AlertOverlayLayer(
|
||||
new AlertRenderer(),
|
||||
clipDecoderFactory: (path, w, h) => new AlertClipDecoder(
|
||||
path, w, h, locator, () => new FfmpegDecodeProcess(),
|
||||
frameRateProbe: new FfmpegFrameRateProbe(locator, () => new FfmpegDecodeProcess())),
|
||||
clipPathResolver: b => b.AlertVideoPath);
|
||||
layer.UpdateConfiguredBoxes(new[] { scene });
|
||||
|
||||
layer.Enqueue(new ChatMessage
|
||||
{
|
||||
Kind = ChatEventKind.SuperChat,
|
||||
AuthorName = "Funder",
|
||||
SuperChatDisplayString = "$5.00",
|
||||
});
|
||||
Assert.True(layer.IsPlaying);
|
||||
|
||||
// The decoder is paced to the media clock, so the first frame arrives in
|
||||
// wall-clock time — poll for it instead of racing it.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(20);
|
||||
while (DateTime.UtcNow < deadline && layer.RenderFrame(box) == null)
|
||||
Thread.Sleep(25);
|
||||
|
||||
layer.Advance(0.4); // past the 0.3s fade-in → opaque
|
||||
var played = layer.RenderFrame(box);
|
||||
Assert.True(played != null,
|
||||
$"the real decoder produced no frame for {clip} — alpha0={AlphaStats(played)}");
|
||||
Assert.Equal(255, played!.BgraPixels[3]);
|
||||
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectWidth = MasterW,
|
||||
SourceRectHeight = MasterH,
|
||||
OutputWidth = MasterW,
|
||||
OutputHeight = MasterH,
|
||||
};
|
||||
var compositor = new SceneCompositor();
|
||||
var withClip = compositor.Render(scene, e => ReferenceEquals(e, box) ? layer.RenderFrame(box) : null, null, options);
|
||||
var idle = compositor.Render(scene, _ => null, null, options);
|
||||
|
||||
// The proof: the box rect is a COLORFUL picture that the idle canvas does
|
||||
// not have. Opaque black in both (the compositor fills its base) is not a
|
||||
// pass — the clip's own pixels have to be there.
|
||||
var colorful = ColorfulPixels(withClip, BoxX, BoxY, BoxW, BoxH);
|
||||
var changed = ChangedPixels(withClip, idle, BoxX, BoxY, BoxW, BoxH);
|
||||
Assert.True(colorful > BoxW * BoxH / 2,
|
||||
$"the decoded alert frame must paint the box rect in colour, only {colorful}/{BoxW * BoxH} px colourful");
|
||||
Assert.True(changed > BoxW * BoxH / 2,
|
||||
$"the alert box rect must differ from the idle canvas, only {changed}/{BoxW * BoxH} px changed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(clip)) File.Delete(clip); } catch { /* temp file */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pixels with actual colour in a rect — opaque black (the compositor's
|
||||
/// base fill) does not count, a decoded frame does.</summary>
|
||||
private static int ColorfulPixels(VideoFrame frame, int x, int y, int w, int h)
|
||||
{
|
||||
var lit = 0;
|
||||
for (var row = y; row < y + h && row < frame.Height; row++)
|
||||
for (var col = x; col < x + w && col < frame.Width; col++)
|
||||
{
|
||||
var i = (row * frame.Width + col) * 4;
|
||||
if (frame.BgraPixels[i] != 0 || frame.BgraPixels[i + 1] != 0 || frame.BgraPixels[i + 2] != 0)
|
||||
lit++;
|
||||
}
|
||||
return lit;
|
||||
}
|
||||
|
||||
private static int ChangedPixels(VideoFrame a, VideoFrame b, int x, int y, int w, int h)
|
||||
{
|
||||
var changed = 0;
|
||||
for (var row = y; row < y + h && row < a.Height; row++)
|
||||
for (var col = x; col < x + w && col < a.Width; col++)
|
||||
{
|
||||
var i = (row * a.Width + col) * 4;
|
||||
if (a.BgraPixels[i] != b.BgraPixels[i] || a.BgraPixels[i + 1] != b.BgraPixels[i + 1]
|
||||
|| a.BgraPixels[i + 2] != b.BgraPixels[i + 2] || a.BgraPixels[i + 3] != b.BgraPixels[i + 3])
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static string AlphaStats(VideoFrame? frame)
|
||||
=> frame == null ? "no frame" : $"alpha={frame.BgraPixels[3]}";
|
||||
|
||||
/// <summary>A 3s colourful clip with a tone — the same shape the alert box plays.</summary>
|
||||
private static void MakeClip(string ffmpeg, string path)
|
||||
{
|
||||
var psi = new ProcessStartInfo(ffmpeg)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
foreach (var a in new[]
|
||||
{
|
||||
"-y", "-hide_banner", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", $"testsrc2=size={BoxW}x{BoxH}:rate=30",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
|
||||
"-t", "3", "-c:v", "mpeg4", "-q:v", "3", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-shortest", path,
|
||||
})
|
||||
psi.ArgumentList.Add(a);
|
||||
|
||||
using var proc = Process.Start(psi)!;
|
||||
var stderr = proc.StandardError.ReadToEnd();
|
||||
Assert.True(proc.WaitForExit(60_000), "ffmpeg did not finish generating the test clip");
|
||||
Assert.True(proc.ExitCode == 0, $"ffmpeg failed to generate the test clip: {stderr}");
|
||||
Assert.True(File.Exists(path), $"ffmpeg produced no clip at {path}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user