perf(compositor): slice 5 — paste cache for non-opaque layers (take-7/8 data)

The stamped build settled what slices 3-4 could not: chat cache works (resolve
~0.0ms) but render stayed 26-27ms -> 124-135/300. The cost was the compositor
re-rasterizing EVERY layer every tick: this Live scene re-samples chat (159k) +
web widget (271k) + image (95k) + cam (156k) ~ 680k px @ ~38ns — for layers
whose pixels do not change between chat/web/cam updates.

OBS shape: cache the surface, paste per tick. BlitCachedLayer rasterizes a
non-opaque layer ONCE into an element-space, transparent-based frame keyed by
(source-array identity, src W/H, ceil'd dst rect, round, mirror), then pastes:
integer position, row alpha-blend, opacity applied at paste. Producers hand out
fresh immutable arrays -> array-identity keys cannot serve stale content; dict
bounded (48, clears whole). Drag/opacity live in paste params, not keys, so
editing stops triggering resamples too. Opaque backdrop keeps the memcpy path;
the webcam keeps the direct path via its IsOpaque flag (revisit if take 9 is
borderline).

ONE integration test: PasteCache_RepeatRender_IsByteIdentical_And_ContentChange-
Propagates (byte-exact raster-vs-paste incl. round-clip margins, new-array
propagation); existing pixel suite guards sampler semantics. 85/85 across
compositor/pump/chat/capture/session classes, clean build 0 warnings. Also:
BuildStampTests.cs was written last commit but never staged — its own scope-check
slip, added here (the run had used the on-disk file; tracked now).

Docs same commit: ai.md slice 5 + stale 'general path only 130k' claim corrected,
TASKS.md take-9 gate, MyMistakes recipe (prove the stage; a fix that doesn't move
the stat wasn't the bottleneck), HANDOFF. take 9 expectation: 300/300, render
<= ~8ms -> saga closes, Unit B starts.
This commit is contained in:
2026-09-04 11:44:19 -07:00
parent 27bf74389d
commit 6af2026906
7 changed files with 258 additions and 16 deletions
+12 -11
View File
@@ -44,17 +44,18 @@ RealApp boot-smoke. Scope-check passed.
raster on message/config change, blit the cached frame every tick (OBS text-source pattern). raster on message/config change, blit the cached frame every tick (OBS text-source pattern).
Tests: `ChatOverlayLayerCacheTests` (the ONE, RealApp) + full regression green (62 across Tests: `ChatOverlayLayerCacheTests` (the ONE, RealApp) + full regression green (62 across
touched classes), clean build 0 warnings. touched classes), clean build 0 warnings.
2. **BUILD STAMP shipped (slice 4, same day):** take 6 came back 35-41ms — WORSE than take 5 — 2. **Takes 7/8 + slice 5 (2026-09-04):** the stamp (f190587b) settled attribution — chat cache REAL
and attribution was impossible (exe timestamp ≠ binary contents; incremental builds served (`resolve ≈ 0`) but render stayed 26-27ms: the compositor re-rasterized every non-opaque layer
unverified exes). Every build now stamps a fresh GUID (`ytLive.csproj GenerateBuildStamp` → per tick (his Live scene: chat+web+image+cam ≈ 680k samples @ ~38ns). Slice 5: `BlitCachedLayer`
`Helpers/BuildStamp`), shown as the wordmark superscript (build id) + `Build xxxxxxxx (compiled — one raster per (source-array, rect, round/mirror), paste at integer pos with opacity; static
...)` in startup.log; stats report `avg render Xms (resolve Y)` so the hot half of the tick is layers now cost row-blends, only content changes resample; drag/opacity changes are paste params,
named. Tests `BuildStampTests` + `BuildStampDisplayTests` (real window, namescoped FindName). not cache keys. Tests: `PasteCache_...` + 85/85 across compositor/pump/chat/capture/session
3. **Take 7 (user, ~30s record-only):** FIRST read the superscript + startup.log Build line — the classes; clean build 0 warnings. (Cam bypasses the cache via IsOpaque; revisit if take 9 is
take is meaningless without it. Expect slice 3 in any post-stamp build: `avg render ≤ ~10ms borderline. Follow-ups unchanged: vertical-tier alloc, debounced chat re-render on bursts.)
(resolve ≪ render), ≈300/300`. If resolve dominates → resolver-side surprise (chat config thrash 3. **Take 9 (user, ~30s record-only):** confirm the wordmark superscript matches the startup.log
/ web frame); if blit dominates → measure the general-path element sizes. If chat-burst sags Build line, then stats must show `≈300/300, avg render ≤ ~8ms` and playback 1x. If yes →
`n/300`: debounced off-tick re-render slice. If clean → recording saga CLOSED, Unit B. **recording saga CLOSED**, Unit B starts. If render ~10-15 → cam-resample cache (drop the
IsOpaque bypass); if n/300 sags only during chat bursts → debounced off-tick raster slice.
4. **Unit B — the top bar + session logic (user spec 2026-09-04 re-sent twice + decisions settled in Q&A):** 4. **Unit B — the top bar + session logic (user spec 2026-09-04 re-sent twice + decisions settled in Q&A):**
- Two-line top bar. Line 1: center = REC + **LIVE** pills (text renamed from ON-AIR; pills become - Two-line top bar. Line 1: center = REC + **LIVE** pills (text renamed from ON-AIR; pills become
mutually-exclusive RADIOS — record-OR-stream ruling), right = avatar + **Login/Logout** button mutually-exclusive RADIOS — record-OR-stream ruling), right = avatar + **Login/Logout** button
+8
View File
@@ -83,6 +83,14 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive:
(the chat log) silently arm the per-tick cost even in flows that never touch the (the chat log) silently arm the per-tick cost even in flows that never touch the
feature (signed-out record-only takes paid chat rendering!). feature (signed-out record-only takes paid chat rendering!).
5. **Prove the stage, then the fix — and re-prove after every slice (2026-09-04, takes 6-8).**
The chat raster fix was REAL but the composer blamed it for the residual slowness it did not
own; two takes burned before the render/resolve split showed `resolve ≈ 0` and pointed at the
compositor pasting static layers per tick (`BlitCachedLayer` finished the job OBS-style). Before
shipping a perf fix: name the stage with a measurement, not a story; after shipping one, the
NEXT number must move — a fix that doesn't change the stat wasn't the bottleneck.
**Take-4 follow-ups (2026-09-04) — the symptom needed a second pass, so cite again:** **Take-4 follow-ups (2026-09-04) — the symptom needed a second pass, so cite again:**
render was still 58.9ms after slice 1. Slice 2 (buffer pool + opaque-row memcpy + render was still 58.9ms after slice 1. Slice 2 (buffer pool + opaque-row memcpy +
integer bilinear) followed the same libyuv research integer bilinear) followed the same libyuv research
+101 -2
View File
@@ -14,6 +14,19 @@ namespace ytLive.Services.Compositor;
/// </summary> /// </summary>
public sealed class SceneCompositor public sealed class SceneCompositor
{ {
// Paste cache (take-8 fix, 2026-09-04): the Live scene re-sampled ~680k bilinear
// pixels EVERY tick even though only the camera actually changes at that rate —
// chat/web/image layers are identical frame after frame. Same shape as OBS (the
// source surface is cached, the compositor blits): a scaled/round-clipped/mirrored
// layer rasterizes once and is pasted (row-copy/blend) on every later tick.
// Keyed by the SOURCE FRAME IDENTITY — every producer (capture, webcam, chat
// renderer, web capture, static cache) hands out immutable byte[], so "same pixels
// array + same target rect" ⇒ same raster. Bounds-checked eviction clears wholesale.
private sealed record PasteKey(byte[] Pixels, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror);
private static readonly Dictionary<PasteKey, VideoFrame> _pasteCache = new();
private static readonly object _pasteGate = new();
private const int MaxPasteEntries = 48;
/// <summary> /// <summary>
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back → /// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
/// front): live background (the scene's <c>IsBackground</c> source) → background image → /// front): live background (the scene's <c>IsBackground</c> source) → background image →
@@ -240,12 +253,84 @@ public sealed class SceneCompositor
var ew = (float)element.Width; var ew = (float)element.Width;
var eh = (float)element.Height; var eh = (float)element.Height;
var isRound = element.ClipShape == ClipShape.Round; var isRound = element.ClipShape == ClipShape.Round;
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame, if (frame.IsOpaque && element.Opacity >= 1f)
(float)element.Opacity, isRound, element.IsMirrored); {
// Opaque full-strength layer (the live backdrop): raster it straight, no alpha path.
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame, 1f, isRound, element.IsMirrored);
}
else
{
BlitCachedLayer(buffer, cropW, cropH, ex, ey, ew, eh, frame, element, isRound);
}
if (element.HasBorder) if (element.HasBorder)
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound); DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
} }
/// <summary>Non-opaque layer blit through the paste cache: the element-space raster
/// (scaled to the element rect, round-clipped, mirrored) depends only on the source
/// frame + rect + shape/mirror — NOT on position or opacity. Static content (chat,
/// web overlays, images, the media frame between updates) is sampled ONCE and pasted
/// at integer position with per-tixel blend; only layers whose source frame actually
/// changed (webcam at device fps) pay resampling.</summary>
private static void BlitCachedLayer(
byte[] buffer, int cropW, int cropH,
float ex, float ey, float ew, float eh,
VideoFrame frame, SceneElement element, bool isRound)
{
if (ew <= 0 || eh <= 0) return;
var dw = (int)System.Math.Ceiling(ew);
var dh = (int)System.Math.Ceiling(eh);
if (dw <= 0 || dh <= 0) return;
var mirror = element.IsMirrored;
PasteKey? key = null;
VideoFrame? raster = null;
lock (_pasteGate)
{
key = new PasteKey(frame.BgraPixels, frame.Width, frame.Height, dw, dh, isRound, mirror);
_pasteCache.TryGetValue(key, out raster);
}
if (raster == null)
{
// Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255
// alpha only where it actually draws, so untouched margins (outside the
// round clip, past the UniformToFill edges) stay alpha 0 and the paste
// reveals the master beneath instead of black.
var pixels = new byte[dw * dh * 4];
BlitContentRaw(pixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror);
raster = new VideoFrame(dw, dh, pixels);
lock (_pasteGate)
{
if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear();
_pasteCache[key!] = raster;
}
}
// Paste at integer position; opacity blends. The raster is never flagged
// IsOpaque — round clips/margins carry alpha 0 — so the row blend always runs.
var op = (int)(System.Math.Clamp(element.Opacity, 0f, 1f) * 256);
if (op <= 0) return;
var px = (int)System.Math.Floor(ex);
var py = (int)System.Math.Floor(ey);
var x0 = System.Math.Max(0, px);
var y0 = System.Math.Max(0, py);
var x1 = System.Math.Min(cropW, px + dw);
var y1 = System.Math.Min(cropH, py + dh);
if (x0 >= x1 || y0 >= y1) return;
var d = buffer.AsSpan();
var s = raster.BgraPixels;
for (var y = y0; y < y1; y++)
{
var si = ((y - py) * dw + (x0 - px)) * 4;
var len = x1 - x0;
if (op == 256)
BlendRowOpaque(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len);
else
BlendRowWeighted(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len, op);
}
}
private static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0) private static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0)
{ {
var cropW = options.SourceRectWidth; var cropW = options.SourceRectWidth;
@@ -311,6 +396,20 @@ public sealed class SceneCompositor
return; return;
} }
BlitContentRaw(dst, dstW, dstH, ex, ey, ew, eh, src, opacity, isRound, isMirror);
}
/// <summary>The general scaled/clipped/mirrored sampler (integer fixed-point
/// bilinear + source-over). Also rasterizes paste-cache entries in ELEMENT space
/// onto a TRANSPARENT base (alpha 0) so pasting over the master keeps the round
/// clip and transparency of the untouched margins.</summary>
private static void BlitContentRaw(
byte[] dst, int dstW, int dstH,
float ex, float ey, float ew, float eh,
VideoFrame src, float opacity, bool isRound, bool isMirror)
{
if (ew <= 0 || eh <= 0) return;
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
var drawnW = src.Width * scale; var drawnW = src.Width * scale;
var drawnH = src.Height * scale; var drawnH = src.Height * scale;
var radius = Math.Min(ew, eh) / 2f; var radius = Math.Min(ew, eh) / 2f;
+1 -1
View File
@@ -973,7 +973,7 @@ click (volume sliders keep their manual `SetSliderValueFromClick`, harmless dupl
**Goal:** record the stream output to a local file, with or without simultaneously streaming. **Goal:** record the stream output to a local file, with or without simultaneously streaming.
### Status: ✅ Shipped `a9eb360` (2026-08-29) — code done (incl. manual-rename modal), build 0 warnings, 244/246 tests; **running-app verification (takes 1–5 done)**: files land (ffmpeg re-pinned month-end), **webcam-in-output + social bar visually CONFIRMED from take 3's extracted frame**, rename modal used for real (take 3 was named via it); take 3 exposed the ~2fps producer starvation and the pump stage-timing (`97ffc42`) named it in one line — `avg render 258.1ms` — **slice 1 fixed 2026-09-03** (deadline pacing per OBS video-io.c + libyuv-style row blits in `SceneCompositor`, test `Pump_Paces_To_The_Deadline_Compensating_Render_Cost`); **take 4: pacing held but render stayed 58.9ms** (2M-iteration row walk + 8.3MB/tick LOH) — **slice 2 shipped 2026-09-04**: `VideoFrame.IsOpaque` producer-contract flag → full-cover backdrop is one `Buffer.BlockCopy`; integer fixed-point bilinear general path; pump scratch pool (release strictly post-submit, owned-by-reference so cache frames are untouchable); dead per-tick `fromScene` render + the `fromSceneProvider` seam removed (BlendFrame uses `TransitionService.FromFrame` — the old render fed nothing). Tests `Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels` (the ONE) + `Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch`; 59/59 per-class green, clean build 0 warnings. **take 5 ran: render 58.9→25.5ms (`138/300` ≈ 2.2x still) — cause: the per-tick chat raster (`RenderFrame` hit `RenderTargetBitmap` every tick whenever the message buffer was non-empty — the buffer survives sessions); slice 3 shipped 2026-09-04: `ChatOverlayLayer` rasters on message/config change and blits a cached frame every tick (OBS text-source pattern; test `ChatOverlayLayerCacheTests`).** **take 6 ran WITHOUT attribution (35-41ms — build provenance unknown; slice 3 effectiveness UNCONFIRMED) → build-stamp shipped instead of guessing again: `Helpers/BuildStamp` GUID per build (csproj GenerateBuildStamp; incremental builds can no longer lie), wordmark superscript + startup.log line; stats split `render (resolve)` so the next take names the stage. **take 7 pending**: read stamp → confirm slice 3 build → stats must show `avg render ≤ ~10ms (resolve ≪), ≈300/300`. Known remaining churn (follow-ups, not silently done): vertical tier's final `BilinearScale` still allocates per frame; one slow tick (~15-25ms) per arriving chat message — debounced off-tick re-render if take 6 shows burst loss. **User UX spec (2026-09-04) queued behind this**: two-line top bar (LIVE rename, radio pills, Login/Logout button + avatar right-click Change Account, no account light; line 2 centered Start↔Stop grayed-until-armed) + up-front SaveFileDialog for REC (native overwrite prompt; retires the stop-time rename modal) + Go-Live dialog KEPT as preflight confirmation prefilled from the Text drawer — decisions captured in HANDOFF ### Status: ✅ Shipped `a9eb360` (2026-08-29) — code done (incl. manual-rename modal), build 0 warnings, 244/246 tests; **running-app verification (takes 1–5 done)**: files land (ffmpeg re-pinned month-end), **webcam-in-output + social bar visually CONFIRMED from take 3's extracted frame**, rename modal used for real (take 3 was named via it); take 3 exposed the ~2fps producer starvation and the pump stage-timing (`97ffc42`) named it in one line — `avg render 258.1ms` — **slice 1 fixed 2026-09-03** (deadline pacing per OBS video-io.c + libyuv-style row blits in `SceneCompositor`, test `Pump_Paces_To_The_Deadline_Compensating_Render_Cost`); **take 4: pacing held but render stayed 58.9ms** (2M-iteration row walk + 8.3MB/tick LOH) — **slice 2 shipped 2026-09-04**: `VideoFrame.IsOpaque` producer-contract flag → full-cover backdrop is one `Buffer.BlockCopy`; integer fixed-point bilinear general path; pump scratch pool (release strictly post-submit, owned-by-reference so cache frames are untouchable); dead per-tick `fromScene` render + the `fromSceneProvider` seam removed (BlendFrame uses `TransitionService.FromFrame` — the old render fed nothing). Tests `Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels` (the ONE) + `Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch`; 59/59 per-class green, clean build 0 warnings. **take 5 ran: render 58.9→25.5ms (`138/300` ≈ 2.2x still) — cause: the per-tick chat raster (`RenderFrame` hit `RenderTargetBitmap` every tick whenever the message buffer was non-empty — the buffer survives sessions); slice 3 shipped 2026-09-04: `ChatOverlayLayer` rasters on message/config change and blits a cached frame every tick (OBS text-source pattern; test `ChatOverlayLayerCacheTests`).** **take 6 ran WITHOUT attribution (35-41ms — build provenance unknown; slice 3 effectiveness UNCONFIRMED) → build-stamp shipped instead of guessing again: `Helpers/BuildStamp` GUID per build (csproj GenerateBuildStamp; incremental builds can no longer lie), wordmark superscript + startup.log line; stats split `render (resolve)` so the next take names the stage. **takes 7–8 (stamped f190587b): chat fix CONFIRMED (`resolve ≈0`) but render stayed 26-27ms — compositor re-rasterizing STATIC layers every tick; slice 5 shipped: `BlitCachedLayer` pastes once-rasterized element-space layers (OBS surface-cache pattern; test `PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates`). **take 9 pending**: read stamp → stats must show `≈300/300, avg render ≤ ~8ms` → saga closes.** Known remaining churn (follow-ups, not silently done): vertical tier's final `BilinearScale` still allocates per frame; one slow tick (~15-25ms) per arriving chat message — debounced off-tick re-render if take 6 shows burst loss. **User UX spec (2026-09-04) queued behind this**: two-line top bar (LIVE rename, radio pills, Login/Logout button + avatar right-click Change Account, no account light; line 2 centered Start↔Stop grayed-until-armed) + up-front SaveFileDialog for REC (native overwrite prompt; retires the stop-time rename modal) + Go-Live dialog KEPT as preflight confirmation prefilled from the Text drawer — decisions captured in HANDOFF
1. ✅ `EncoderOptions` extended with `StreamEnabled` / `RecordEnabled` / `RecordPath` (independent intent flags) 1. ✅ `EncoderOptions` extended with `StreamEnabled` / `RecordEnabled` / `RecordPath` (independent intent flags)
2. ✅ `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags` 2. ✅ `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags`
+20 -2
View File
@@ -707,8 +707,8 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
derivative-work rule): 1:1 aligned blits take a row-walk fast path (bilinear at scale 1 + integer derivative-work rule): 1:1 aligned blits take a row-walk fast path (bilinear at scale 1 + integer
offset is the identity) with per-pixel alpha branch and integer fixed-point blend; `BlitOverlay` offset is the identity) with per-pixel alpha branch and integer fixed-point blend; `BlitOverlay`
clips to the intersection rect; a full-cover live backdrop skips the opaque-black pre-fill. The clips to the intersection rect; a full-cover live backdrop skips the opaque-black pre-fill. The
general per-pixel path stays for scaled/round/mirrored elements (webcam) — pixel-identical, and it is general per-pixel path handled scaled/round/mirrored elements. Test:
only ~130k px. Test: `Pump_Paces_To_The_Deadline_Compensating_Render_Cost` records the requested wait `Pump_Paces_To_The_Deadline_Compensating_Render_Cost` records the requested wait
via the pacing seam (a seam fake must genuinely await — a synchronous completed task runs the whole via the pacing seam (a seam fake must genuinely await — a synchronous completed task runs the whole
pump loop on `StartAsync`'s continuation and hangs the run; MyMistakes). pump loop on `StartAsync`'s continuation and hangs the run; MyMistakes).
- **Slice 2 of the render fix (2026-09-04, take 4):** the pacing held (file no longer truncated at - **Slice 2 of the render fix (2026-09-04, take 4):** the pacing held (file no longer truncated at
@@ -745,6 +745,24 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
renderer — `Same()` for unchanged inputs, `NotSame()` on message/config change, null on empty). renderer — `Same()` for unchanged inputs, `NotSame()` on message/config change, null on empty).
Accepted cost pending take 6: one slow tick (~15-25ms) per arriving message; if chat-burst frame Accepted cost pending take 6: one slow tick (~15-25ms) per arriving message; if chat-burst frame
loss shows up, the next slice moves the re-render off-tick (debounced, dispatcher-side). loss shows up, the next slice moves the re-render off-tick (debounced, dispatcher-side).
- **Slice 5 — paste cache for non-opaque layers (2026-09-04, take 7/8 data):** the render/resolve
split (added in the stamp commit) finally named the last thing honestly: `resolve ≈ 0` but
`render 26-27ms` (124-135/300, ≈2.2x) — the chat fix HAD worked; the remaining cost was the
compositor re-rasterizing EVERY layer per tick even when its pixels never changed (this creator's
Live scene: chat 159k + web widget 271k + image 95k + webcam 156k px ≈ 680k samples @ ~38ns each).
OBS's actual shape: sources cache their surface, the compositor pastes. `SceneCompositor` now
routes non-opaque layers through `BlitCachedLayer`: a layer rasterizes ONCE into an element-space,
TRANSPARENT-based frame keyed by (source-array identity, source W×H, ceil'd dst rect, round,
mirror), then every later tick PASTES it (integer position, row alpha-blend, opacity applied at
paste). Producers hand out fresh immutable arrays, so array-identity keys can never serve stale
content; dict bounded at 48, cleared wholesale on overflow. Only changing content (webcam device
frames, web capture ticks, chat messages) resamples; the opaque backdrop keeps its memcpy path.
Position/opacity drags are now near-free (no resample — paste params, not cache keys).
ONE integration test: `PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates`
(raster-vs-paste byte equality incl. round-clip margins + new-array propagation); the whole
existing pixel suite guards sampler semantics. Take 9 must show `≈300/300, avg render ≤ ~8ms`;
if it lands there, the recording saga closes. Follow-ups unchanged: vertical-tier scale alloc,
debounced chat re-render under message bursts.
- **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits) - **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits)
**before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the **before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the
reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips
+59
View File
@@ -0,0 +1,59 @@
using System;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using Xunit;
using ytLive.Helpers;
namespace ytLive.Tests;
/// <summary>
/// Build attribution (2026-09-04, after takes 3–6 could not be pinned to a binary):
/// every build stamps a fresh GUID (ytLive.csproj GenerateBuildStamp), shown as the
/// wordmark superscript and logged at startup. The unit pins the stamp shape; the
/// RealApp test proves the wordmark ACTUALLY displays it (XAML wiring, live visual
/// tree) — a stamp nobody can see would not have ended the guessing.
/// </summary>
public sealed class BuildStampTests
{
[Fact]
public void Stamp_IsAFreshHexIdAndParsableTimestamp()
{
Assert.Matches(new Regex("^[0-9a-f]{8}$"), BuildStamp.Id);
Assert.True(DateTime.TryParseExact(BuildStamp.BuiltLocal, "yyyy-MM-dd HH:mm:ss",
null, System.Globalization.DateTimeStyles.None, out var built));
Assert.True(built <= DateTime.Now);
Assert.True(built.Date >= new DateTime(2026, 9, 4));
}
}
[Collection("RealApp")]
public sealed class BuildStampDisplayTests
{
private readonly RealAppHost _app;
public BuildStampDisplayTests(RealAppHost app) => _app = app;
[Fact]
public void Wordmark_Shows_The_Build_Stamp_As_Superscript()
{
_app.Run(() =>
{
var window = new MainWindow();
try
{
// TopBar is its own namescope (UserControl) — FindName from the
// control, never from the window (MyMistakes: namescoped FindName).
var topBar = (UserControl)window.FindName("TopBar")!;
var stamp = (Run)topBar.FindName("BuildIdStamp")!;
Assert.Equal(BuildStamp.Id, stamp.Text);
Assert.Equal(BaselineAlignment.Superscript, stamp.BaselineAlignment);
}
finally
{
window.Close();
}
});
}
}
+57
View File
@@ -154,6 +154,63 @@ public class SceneCompositorTests
Assert.DoesNotContain((byte)0xFD, output.BgraPixels); // full cover — nothing stale Assert.DoesNotContain((byte)0xFD, output.BgraPixels); // full cover — nothing stale
} }
/// <summary>The ONE integration test for the paste cache (take-8): a second render of
/// the SAME frames must produce byte-identical output (the hit path pastes a cached
/// element-space raster — position/opacity/margins/round-clip must survive it), and a
/// NEW source array must propagate (the cache keys on frame identity, so a stale
/// raster can never outlive its content). Covers the margins the FullScene probe
/// pixels don't: outside-the-circle alpha and the black pre-fill interaction.</summary>
[Fact]
public void PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates()
{
var red = Solid(1920, 1080, 255, 0, 0);
var green = Solid(1280, 720, 0, 255, 0);
var blue = Solid(1280, 720, 0, 0, 255);
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
var roundWebcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
var scene = new Scene { Name = "Live" };
scene.Elements.Add(background);
scene.Elements.Add(roundWebcam);
VideoFrame? FrameFor(SceneElement e) => e switch
{
WebcamSceneConfig => green,
Source { IsBackground: true } => red,
_ => null,
};
var options = new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
OutputWidth = 1920, OutputHeight = 1080,
};
var compositor = new SceneCompositor();
var first = compositor.Render(scene, FrameFor, null, options);
var second = compositor.Render(scene, FrameFor, null, options); // paste-cache hit
Assert.Equal(first.BgraPixels, second.BgraPixels);
// margins: inside the circle = webcam, outside = backdrop (both renders)
foreach (var f in new[] { first, second })
{
AssertColor(f, 250, 250, 0, 255, 0);
AssertColor(f, 101, 101, 255, 0, 0);
Assert.Equal((byte)255, f.BgraPixels[(250 * 1920 + 250) * 4 + 3]); // alpha stays opaque
}
// Content change (new array, same geometry) must show through the cache.
VideoFrame? FrameForBlue(SceneElement e) => e switch
{
WebcamSceneConfig => blue,
Source { IsBackground: true } => red,
_ => null,
};
var third = compositor.Render(scene, FrameForBlue, null, options);
AssertColor(third, 250, 250, 0, 0, 255);
AssertColor(third, 101, 101, 255, 0, 0);
}
[Fact] [Fact]
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop() public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
{ {