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:
+12
-11
@@ -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).
|
||||
Tests: `ChatOverlayLayerCacheTests` (the ONE, RealApp) + full regression green (62 across
|
||||
touched classes), clean build 0 warnings.
|
||||
2. **BUILD STAMP shipped (slice 4, same day):** take 6 came back 35-41ms — WORSE than take 5 —
|
||||
and attribution was impossible (exe timestamp ≠ binary contents; incremental builds served
|
||||
unverified exes). Every build now stamps a fresh GUID (`ytLive.csproj GenerateBuildStamp` →
|
||||
`Helpers/BuildStamp`), shown as the wordmark superscript (build id) + `Build xxxxxxxx (compiled
|
||||
...)` in startup.log; stats report `avg render Xms (resolve Y)` so the hot half of the tick is
|
||||
named. Tests `BuildStampTests` + `BuildStampDisplayTests` (real window, namescoped FindName).
|
||||
3. **Take 7 (user, ~30s record-only):** FIRST read the superscript + startup.log Build line — the
|
||||
take is meaningless without it. Expect slice 3 in any post-stamp build: `avg render ≤ ~10ms
|
||||
(resolve ≪ render), ≈300/300`. If resolve dominates → resolver-side surprise (chat config thrash
|
||||
/ web frame); if blit dominates → measure the general-path element sizes. If chat-burst sags
|
||||
`n/300`: debounced off-tick re-render slice. If clean → recording saga CLOSED, Unit B.
|
||||
2. **Takes 7/8 + slice 5 (2026-09-04):** the stamp (f190587b) settled attribution — chat cache REAL
|
||||
(`resolve ≈ 0`) but render stayed 26-27ms: the compositor re-rasterized every non-opaque layer
|
||||
per tick (his Live scene: chat+web+image+cam ≈ 680k samples @ ~38ns). Slice 5: `BlitCachedLayer`
|
||||
— one raster per (source-array, rect, round/mirror), paste at integer pos with opacity; static
|
||||
layers now cost row-blends, only content changes resample; drag/opacity changes are paste params,
|
||||
not cache keys. Tests: `PasteCache_...` + 85/85 across compositor/pump/chat/capture/session
|
||||
classes; clean build 0 warnings. (Cam bypasses the cache via IsOpaque; revisit if take 9 is
|
||||
borderline. Follow-ups unchanged: vertical-tier alloc, debounced chat re-render on bursts.)
|
||||
3. **Take 9 (user, ~30s record-only):** confirm the wordmark superscript matches the startup.log
|
||||
Build line, then stats must show `≈300/300, avg render ≤ ~8ms` and playback 1x. If yes →
|
||||
**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):**
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
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:**
|
||||
render was still 58.9ms after slice 1. Slice 2 (buffer pool + opaque-row memcpy +
|
||||
integer bilinear) followed the same libyuv research
|
||||
|
||||
@@ -14,6 +14,19 @@ namespace ytLive.Services.Compositor;
|
||||
/// </summary>
|
||||
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>
|
||||
/// 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 →
|
||||
@@ -240,12 +253,84 @@ public sealed class SceneCompositor
|
||||
var ew = (float)element.Width;
|
||||
var eh = (float)element.Height;
|
||||
var isRound = element.ClipShape == ClipShape.Round;
|
||||
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame,
|
||||
(float)element.Opacity, isRound, element.IsMirrored);
|
||||
if (frame.IsOpaque && element.Opacity >= 1f)
|
||||
{
|
||||
// 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)
|
||||
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)
|
||||
{
|
||||
var cropW = options.SourceRectWidth;
|
||||
@@ -311,6 +396,20 @@ public sealed class SceneCompositor
|
||||
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 drawnH = src.Height * scale;
|
||||
var radius = Math.Min(ew, eh) / 2f;
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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)
|
||||
2. ✅ `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags`
|
||||
|
||||
@@ -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
|
||||
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
|
||||
general per-pixel path stays for scaled/round/mirrored elements (webcam) — pixel-identical, and it is
|
||||
only ~130k px. Test: `Pump_Paces_To_The_Deadline_Compensating_Render_Cost` records the requested wait
|
||||
general per-pixel path handled scaled/round/mirrored elements. Test:
|
||||
`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
|
||||
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
|
||||
@@ -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).
|
||||
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).
|
||||
- **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)
|
||||
**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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,63 @@ public class SceneCompositorTests
|
||||
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]
|
||||
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user