Scene compositor (TASK 4 ship step 1): encoder master-frame scaffolding

Software compositor that renders a scene into the encoder's master VideoFrame,
mirroring the XAML preview minus editing chrome: backdrop -> background ->
elements (UniformToFill cover-crop, round clip, mirror, opacity, border) ->
branding flash.

NOTE FOR USERS: this change shows NO difference in the app's UI — it is pure
backend scaffolding laying the groundwork for live video capture/streaming.
The preview you see is unchanged.

- Services/Compositor/: SceneCompositor (Render(scene, frameFor resolver,
  flashFrame, CompositorOptions)), CompositorOptions (source rect + output
  size; 16:9 full master, vertical 607x1080 -> 1080x1920), StretchMath (pure
  UniformToFill + bilinear), StaticPixelCache (asset bytes -> BGRA8 frame)
- Frame sources injected via Func<SceneElement, VideoFrame?> resolver, so the
  compositor is pure, WPF-free, and hermetic to test (D3D11 upgrade behind the
  same seam later)
- SceneElement.TryGetBorderColor public (shared hex parse), stale
  MainViewModel comment fixed, pre-existing CS1998 in YouTubeAuthServiceTests
  cleaned up
- Tests: SceneCompositorTests integration (full scene + vertical tier + flash)
  + StretchMath units, docs updated (72 tests passing, 0 warnings)
This commit is contained in:
2026-08-10 10:15:58 -07:00
parent b00a4cbd5e
commit 18a21010bb
11 changed files with 681 additions and 11 deletions
+1 -1
View File
@@ -227,7 +227,7 @@ public abstract class SceneElement : INotifyPropertyChanged
}
}
private bool TryGetBorderColor(out byte r, out byte g, out byte b)
public bool TryGetBorderColor(out byte r, out byte g, out byte b)
{
r = g = b = 0;
var hex = BorderColor.Trim().TrimStart('#');
+19
View File
@@ -0,0 +1,19 @@
namespace ytLive.Services.Compositor;
/// <summary>
/// Where the compositor draws from (the active quality tier's output rect over the
/// 1920×1080 master) and at what size. 16:9 tiers = the full master 1:1; the vertical
/// 9:16 tier = the centered 607×1080 crop scaled up to 1080×1920. The source rect is
/// integer-aligned — MainViewModel's <c>OutputRectX</c> can be 656.5, so the caller
/// rounds before building these options.
/// </summary>
public sealed class CompositorOptions
{
public int SourceRectX { get; init; }
public int SourceRectY { get; init; }
public int SourceRectWidth { get; init; }
public int SourceRectHeight { get; init; }
public int OutputWidth { get; init; }
public int OutputHeight { get; init; }
}
+198
View File
@@ -0,0 +1,198 @@
using ytLive.Models;
namespace ytLive.Services.Compositor;
/// <summary>
/// The output compositor: renders a scene into the encoder's master frame (tightly-packed
/// BGRA8 <see cref="VideoFrame"/>) exactly as the XAML preview renders it, minus the
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). The preview stays the
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
///
/// Frame sources are supplied by a <see cref="Func{SceneElement, VideoFrame}"/> resolver
/// (the caller maps webcam → DeviceId, images → AssetId, backdrop → CaptureKey), keeping
/// the compositor pure and free of WPF and of the capture managers.
/// </summary>
public sealed class SceneCompositor
{
/// <summary>
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
/// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
/// branding flash. Transparent regions read opaque black.
/// </summary>
public VideoFrame Render(
Scene scene,
Func<SceneElement, VideoFrame?> frameFor,
VideoFrame? flashFrame,
CompositorOptions options)
{
if (scene == null) throw new ArgumentNullException(nameof(scene));
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
if (options == null) throw new ArgumentNullException(nameof(options));
if (options.SourceRectWidth <= 0 || options.SourceRectHeight <= 0)
throw new ArgumentException("The source rect must be positive.", nameof(options));
if (options.OutputWidth <= 0 || options.OutputHeight <= 0)
throw new ArgumentException("The output size must be positive.", nameof(options));
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
var buffer = new byte[cropW * cropH * 4];
for (var i = 3; i < buffer.Length; i += 4)
buffer[i] = 255; // opaque black base — video frames are never transparent
var elements = scene.Elements;
var backdrop = elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
var backdropFrame = backdrop != null ? frameFor(backdrop) : null;
if (backdropFrame != null)
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backdropFrame, 1f, false, false);
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
var backgroundFrame = background != null ? frameFor(background) : null;
if (backgroundFrame != null)
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
foreach (var element in elements)
{
if (!element.IsVisible) continue;
switch (element)
{
case Source { IsBackdrop: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
continue; // backdrop/background are their own layers; Text isn't shipped
}
var frame = frameFor(element);
if (frame == null) continue;
var ex = (float)(element.X - options.SourceRectX);
var ey = (float)(element.Y - options.SourceRectY);
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 (element.HasBorder)
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
}
if (flashFrame != null)
BlitFlash(buffer, cropW, cropH, options, flashFrame);
return StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}
/// <summary>
/// UniformToFill blit of a source frame into element rect (ex, ey, ew, eh), with
/// straight-alpha source-over, optional round clip (true circle, hard edge) and
/// horizontal mirror around the element center.
/// </summary>
private static void BlitContent(
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 x0 = Math.Max(0, (int)Math.Floor(ex));
var y0 = Math.Max(0, (int)Math.Floor(ey));
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew));
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh));
if (x0 > x1 || y0 > y1) 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;
var cx = ew / 2f;
var cy = eh / 2f;
for (var y = y0; y <= y1; y++)
{
for (var x = x0; x <= x1; x++)
{
var px = x - ex; // element space
var py = y - ey;
if (px < ox || px > ox + drawnW || py < oy || py > oy + drawnH) continue;
if (isRound)
{
var dx = px - cx;
var dy = py - cy;
if (dx * dx + dy * dy > radius * radius) continue;
}
var sx = (px - ox) / scale;
var sy = (py - oy) / scale;
if (isMirror) sx = src.Width - 1 - sx;
var sample = StretchMath.SampleBgra(src.BgraPixels, src.Width, src.Height, sx, sy);
BlendPixel(dst, (y * dstW + x) * 4, sample, opacity);
}
}
}
/// <summary>Centered OBS-style border stroke: rect ring (Traditional) or circle ring (Round).</summary>
private static void DrawBorder(
byte[] dst, int dstW, int dstH,
float ex, float ey, float ew, float eh,
SceneElement element, bool isRound)
{
if (!element.TryGetBorderColor(out var r, out var g, out var b)) return;
var half = element.BorderWidth / 2f;
var alpha = (float)(element.Opacity * element.BorderOpacity);
if (half <= 0 || alpha <= 0) return;
var cx = ex + ew / 2f;
var cy = ey + eh / 2f;
var radius = Math.Min(ew, eh) / 2f;
var x0 = Math.Max(0, (int)Math.Floor(ex - half));
var y0 = Math.Max(0, (int)Math.Floor(ey - half));
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew + half));
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh + half));
for (var y = y0; y <= y1; y++)
{
for (var x = x0; x <= x1; x++)
{
float d = isRound
? MathF.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) - radius
: MathF.Max(MathF.Max(ex - x, x - (ex + ew)), MathF.Max(ey - y, y - (ey + eh)));
if (MathF.Abs(d) <= half)
BlendPixel(dst, (y * dstW + x) * 4, (b, g, r, (byte)255), alpha);
}
}
}
/// <summary>1:1 copy of the master-sized branding flash, cropped to the active source rect.</summary>
private static void BlitFlash(byte[] dst, int dstW, int dstH, CompositorOptions options, VideoFrame flash)
{
for (var y = 0; y < dstH; y++)
{
for (var x = 0; x < dstW; x++)
{
var sx = x + options.SourceRectX;
var sy = y + options.SourceRectY;
if (sx >= flash.Width || sy >= flash.Height) continue;
var sample = StretchMath.SampleBgra(flash.BgraPixels, flash.Width, flash.Height, sx, sy);
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
}
}
}
/// <summary>Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.</summary>
private static void BlendPixel(byte[] dst, int di, (byte B, byte G, byte R, byte A) src, float opacity)
{
var sa = src.A / 255f * opacity;
if (sa <= 0) return;
var da = dst[di + 3] / 255f;
var outA = sa + da * (1 - sa);
if (outA <= 0) return;
dst[di] = (byte)Math.Round((src.B * sa + dst[di] * da * (1 - sa)) / outA);
dst[di + 1] = (byte)Math.Round((src.G * sa + dst[di + 1] * da * (1 - sa)) / outA);
dst[di + 2] = (byte)Math.Round((src.R * sa + dst[di + 2] * da * (1 - sa)) / outA);
dst[di + 3] = (byte)Math.Round(outA * 255);
}
}
+48
View File
@@ -0,0 +1,48 @@
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace ytLive.Services.Compositor;
/// <summary>
/// Decodes a layout asset's bytes into a tightly-packed BGRA8 <see cref="VideoFrame"/>
/// once per content-hash asset id. The preview uses the WPF <c>BitmapImage</c> in
/// ImageCache; the output path needs raw pixels, so assets decode here instead.
/// </summary>
public static class StaticPixelCache
{
private static readonly Dictionary<string, VideoFrame> Cache = new(StringComparer.Ordinal);
public static VideoFrame? Get(string assetId)
{
if (string.IsNullOrWhiteSpace(assetId)) return null;
if (Cache.TryGetValue(assetId, out var frame)) return frame;
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
if (bytes == null || bytes.Length == 0) return null;
var decoded = Decode(bytes);
if (decoded != null) Cache[assetId] = decoded;
return decoded;
}
public static VideoFrame? Decode(byte[] bytes)
{
try
{
using var stream = new MemoryStream(bytes, writable: false);
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
var source = decoder.Frames[0];
var bgra = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0);
var width = bgra.PixelWidth;
var height = bgra.PixelHeight;
var pixels = new byte[width * height * 4];
bgra.CopyPixels(pixels, width * 4, 0);
return new VideoFrame(width, height, pixels);
}
catch
{
return null;
}
}
}
+76
View File
@@ -0,0 +1,76 @@
namespace ytLive.Services.Compositor;
/// <summary>
/// Pure pixel math shared by the compositor: the WPF "UniformToFill" cover-crop
/// (what the preview's <c>Stretch="UniformToFill"</c> does) and a clamped bilinear
/// sample/scale. Pure and deterministic — the unit-tested half of the compositor.
/// </summary>
public static class StretchMath
{
/// <summary>
/// UniformToFill: the drawn content covers the destination while preserving the
/// source aspect, centered; overflow is cropped. Returns the scale and the
/// source-origin offset (in destination pixels) of the drawn content within the
/// destination rect.
/// </summary>
public static (float Scale, float OffsetX, float OffsetY) UniformToFill(float dstW, float dstH, int srcW, int srcH)
{
var scale = Math.Max(dstW / srcW, dstH / srcH);
var drawnW = srcW * scale;
var drawnH = srcH * scale;
return (scale, (dstW - drawnW) / 2f, (dstH - drawnH) / 2f);
}
/// <summary>Clamped bilinear scale into a new tightly-packed BGRA8 frame; returns the input unchanged when the sizes already match.</summary>
public static VideoFrame BilinearScale(VideoFrame src, int outW, int outH)
{
if (outW == src.Width && outH == src.Height) return src;
var outPixels = new byte[outW * outH * 4];
for (var y = 0; y < outH; y++)
{
var sy = (y + 0.5f) / outH * src.Height - 0.5f;
for (var x = 0; x < outW; x++)
{
var sx = (x + 0.5f) / outW * src.Width - 0.5f;
var (b, g, r, a) = SampleBgra(src.BgraPixels, src.Width, src.Height, sx, sy);
var di = (y * outW + x) * 4;
outPixels[di] = b;
outPixels[di + 1] = g;
outPixels[di + 2] = r;
outPixels[di + 3] = a;
}
}
return new VideoFrame(outW, outH, outPixels);
}
/// <summary>
/// Clamped bilinear sample of one BGRA8 pixel at fractional (sx, sy). Coordinates
/// outside [0, w-1]×[0, h-1] clamp to the edge, so a caller can sample freely
/// without bounds checks.
/// </summary>
public static (byte B, byte G, byte R, byte A) SampleBgra(byte[] src, int w, int h, float sx, float sy)
{
sx = Math.Clamp(sx, 0, w - 1);
sy = Math.Clamp(sy, 0, h - 1);
var x0 = (int)sx;
var y0 = (int)sy;
var x1 = Math.Min(x0 + 1, w - 1);
var y1 = Math.Min(y0 + 1, h - 1);
var fx = sx - x0;
var fy = sy - y0;
var p00 = (y0 * w + x0) * 4;
var p10 = (y0 * w + x1) * 4;
var p01 = (y1 * w + x0) * 4;
var p11 = (y1 * w + x1) * 4;
float r = Lerp(Lerp(src[p00 + 2], src[p10 + 2], fx), Lerp(src[p01 + 2], src[p11 + 2], fx), fy);
float g = Lerp(Lerp(src[p00 + 1], src[p10 + 1], fx), Lerp(src[p01 + 1], src[p11 + 1], fx), fy);
float b = Lerp(Lerp(src[p00], src[p10], fx), Lerp(src[p01], src[p11], fx), fy);
float a = Lerp(Lerp(src[p00 + 3], src[p10 + 3], fx), Lerp(src[p01 + 3], src[p11 + 3], fx), fy);
return ((byte)Math.Round(b), (byte)Math.Round(g), (byte)Math.Round(r), (byte)Math.Round(a));
}
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
}
+4
View File
@@ -27,6 +27,10 @@ External-facing logic: YouTube API, persistence. See
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash. Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
+75 -3
View File
@@ -169,8 +169,8 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
### Requirements:
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only
2. **RTMP push** — FFmpeg subprocess or native RTMP library, to the cached reusable stream's ingestion URL
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are.
2. **RTMP push****FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push + reconnect. **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (~30 MB, standard gyan.dev/BtB N — no custom minimal build) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it. Push goes to the cached reusable stream's ingestion URL
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
- 720p30 @ 6 Mbps
- 720p60 @ 6 Mbps
@@ -191,8 +191,80 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
4. **Stream key management** — reuse the cached reusable stream (one per channel) instead of creating a new one per go-live; prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2`
5. **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
6. **One-click go live** — defaults that work out of the box
7. **Audio capture (feeds the meter — this task ships the wiring)** — WASAPI loopback (desktop/game at unity, zero UI — "it just is") + the picked mic (`MicSourceName` from the `MicPickerDialog`). The mic capture feeds `AudioLevel` so the realtime meter comes alive (today it reads 0 — the mixer feed is pending, see `ai.md` audio notes). AAC mono/stereo @ 48 kHz per the compliance rules.
### Status: Not started
### Status: 🔶 In progress — **ship step 1 (the output compositor) SHIPPED** (2026-08-10); encoder/RTMP/audio follow it
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
without the preview's editing chrome) → **audio capture** (WASAPI, feeds the meter) → **H.264+AAC encode**
**vertical-tier crop/scale****RTMP push****health stats** into the bottom bar. Nothing can encode
until a frame source exists, so the compositor is ship step 1.
#### Ship step 1 — Scene compositor (the frame source)
**Goal:** a pure-CPU software compositor producing the encoder's master frame (BGRA8, the `VideoFrame`
seam) from the scene model. The preview stays XAML (the editing view); the compositor is the **output
view** — WPF's `RenderTargetBitmap` can't be used (software-rendered + captures chrome). Two renderers
must agree, so the XAML (`MainWindow.xaml` CanvasGrid + element DataTemplate) is the contract.
**Decisions (locked 2026-08-10):** **Path A CPU blitter** — GPU effort belongs to NVENC (the encoder),
not composition; with an FFmpeg subprocess the master crosses a CPU readback to the pipe every frame
anyway, so GPU compositing buys ~nothing at this layer count (2-3 live layers; static layers
pre-composite once). A D3D11 compositor can replace this one later **behind the same seam** (the CPU
master buffer stays the contract). **Render the output rect directly**: compositor is constructed with
`CompositorOptions {SourceRectX/Y/W/H, OutputWidth, OutputHeight}`; 16:9 tiers = full 1920×1080 1:1;
vertical (9:16) = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920. Reuses
`MainViewModel.OutputRectX/Y/W/H` (note `(1920607)/2 = 656.5` → align to integer pixels for output).
**Render spec (back → front, mirror the XAML exactly):**
1. Backdrop — the Live scene's `IsBackdrop` Source (`CaptureKey` → live frame), `UniformToFill`
full-frame (XAML's separate `BackdropImage` layer; the backdrop *element* renders nothing — its
DataTemplate Image is Collapsed for DisplayCapture).
2. Background — the scene's `Background` Source, `UniformToFill` full-frame (the `ActiveBackgroundImage`
layer, not per-element).
3. Elements in `Scene.Elements` order (back→front), skip `IsVisible=false`. What actually renders:
- `Source` Type `Image` → static asset, `UniformToFill` cover-crop into (X, Y, W, H)
- `WebcamSceneConfig` → latest frame by `DeviceId`: Traditional = `UniformToFill` rect; Round = circle
diameter `min(W,H)` (alpha 0 outside — true circle, not oval); mirror = horizontal flip around
element center (`MirrorScale`); opacity = per-pixel multiply (content + border); border = stroked
rect / centered circle at `RoundBorderSize`, width `BorderWidth`, alpha `BorderOpacity`
- `Background` / `IsBackdrop` / `TextOverlay` are NOT per-element (layers above; Text not shipped)
4. Branding flash — pre-rendered full-frame "made with ytLlive!" at 25% alpha when live +
`BrandFlashEnabled` + timer active. Passed in as a `VideoFrame?` (compositor core stays pure byte-math,
no WPF; likely a bundled asset rather than runtime text rendering).
5. NOT in output (preview chrome only): SelectionOverlay, DimRects, output-rect outline, badge, placeholder.
**New files (all in `Services/Compositor/`):**
- `SceneCompositor.cs` — `Render(Scene, frameFor: Func<SceneElement, VideoFrame?>, flashFrame:
VideoFrame?, CompositorOptions) → VideoFrame` (output-sized). The caller's `frameFor` resolver maps
each element to its frame (webcam → DeviceId, image → AssetId via `StaticPixelCache`, backdrop →
CaptureKey) — the compositor stays pure/hermetic/no WPF.
- `CompositorOptions.cs` — source-rect + output W×H.
- `StretchMath.cs` — `UniformToFill` cover-crop, ellipse mask, bilinear scale (pure, unit-tested).
- `StaticPixelCache.cs` — asset `byte[]` → cached BGRA `VideoFrame` (WPF `BitmapDecoder` + `CopyPixels`,
decode once per content hash).
**Test plan (Good Dog Rule — ONE integration test):** `SceneCompositorTests` — a scene with backdrop
(solid red fake frame) + round webcam (solid green) + image (solid blue) → render 16:9 master → assert
per-layer probe pixels (corner = backdrop color, element center = webcam color, outside the round clip =
backdrop color, mirrored element swaps left/right); a vertical-tier variant asserts 1080×1920 output +
crop fidelity. Focused unit tests on `StretchMath`. Tests push frames directly — no capture managers
involved (they wire in a later step).
**Same-PR housekeeping:** fix the stale comment `MainViewModel.cs:324` ("shown under the meter on line 2"
→ "shown left-justified INSIDE the meter bar" — `ai.md` is the authority); this task's requirements now
include the explicit audio-capture/meter wiring (#7 above).
**Out of scope (later ship steps):** FFmpeg locator + license posture (covered in requirements 1-2),
encoder + RTMP push, WASAPI audio capture (loopback + mic) feeding `AudioLevel`, wiring
`CameraManager`/`ScreenCaptureManager` into the frame pipeline, brand-flash timer wiring, health stats
(bitrate/FPS/dropped).
**Built (2026-08-10):** all four files shipped in `Services/Compositor/`, `SceneElement.TryGetBorderColor`
made public (shared hex parse with the compositor — no duplicated color parsing), the stale
`MainViewModel.cs:324` comment corrected, and the pre-existing CS1998 in `YouTubeAuthServiceTests`
cleaned up — build **0 warnings**. Tests: the `SceneCompositorTests` integration test (full-scene master
pixels, vertical tier, flash) + 4 `StretchMath` units — **72 passing**.
---
+2 -1
View File
@@ -321,7 +321,8 @@ public class MainViewModel : ViewModelBase
public string MicMuteText => MicMuted ? "Unmute" : "Mute";
/// <summary>Name of the picked voice source, shown under the meter on line 2.</summary>
/// <summary>Name of the picked voice source, shown left-justified INSIDE the meter
/// bar (FontSize 10, ellipsized to the bar) — ai.md is the authority here.</summary>
public string? MicSourceName
{
get => _micSourceName;
+33 -3
View File
@@ -59,8 +59,10 @@ ScreenCaptureManager refcount + shared-bitmap + coalescing
(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackdropTests (EnsureBackdrop
insert/idempotent/heal + HasBackdrop gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests
(the five canonical scenes, Live-only backdrop policy, EnforceBackdropPolicy), WebcamSafeguardTests
(the per-scene size clamp incl. the Chat half-screen-area cap)
65 passing.
(the per-scene size clamp incl. the Chat half-screen-area cap), SceneCompositorTests (the full-scene
composite integration test: backdrop + round webcam + mirrored/bordered images + flash; the vertical
tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear) —
72 passing.
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
@@ -110,7 +112,7 @@ C# / WPF (.NET 8) following MVVM:
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
- Scene/source/asset layout persists (SQLite, schema v6); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **window capture (non-backdrop), scene compositing/encoding, RTMP are next**
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is the next build** — full plan in `TASKS.md`; window capture (non-backdrop), the encoder + RTMP push, and audio capture follow it
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
### Screen backdrop capture (TASK 3 ship task #1)
@@ -282,6 +284,34 @@ instead of a normal draggable source.
- **Background removal = milestone 2** — ONNX Runtime + DirectML (CPU fallback), MediaPipe Selfie
Segmentation (Apache-2.0), wired into the same `VideoFrame` seam. Not part of milestone 1.
### Scene compositor (TASK 4 ship step 1 — shipped 2026-08-10, plan in TASKS.md)
The encoder needs the master 1920×1080 frame **without** the preview's editing chrome (SelectionOverlay,
DimRects, output-rect outline, badge, placeholder). WPF's `RenderTargetBitmap` is software-rendered and
captures the visual tree *including* chrome, so the preview can't be captured — the output is a **second,
parallel software compositor** over the `VideoFrame` (BGRA8) seam, and the XAML preview
(`MainWindow.xaml` CanvasGrid + element DataTemplate) is the rendering contract it replicates. Two
renderers must agree: geometry, `UniformToFill` cover-crop, round clip, mirror, border, z-order. Preview
stays XAML (editing view); the compositor is the output view.
- **Render the active tier's output rect directly** (`CompositorOptions {SourceRectX/Y/W/H,
OutputWidth, OutputHeight}`, fed from `MainViewModel.OutputRect*`): 16:9 = full 1920×1080 1:1; the
vertical 9:16 tier = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920.
- **CPU posture:** with FFmpeg as a subprocess the master crosses a CPU readback to the pipe every frame
anyway, so GPU compositing buys little at this layer count (2-3 live layers; static layers
pre-composite once into a cached base). GPU effort belongs to **NVENC** (the encoder), not composition;
if composition grows (wipes, filters, many layers), a D3D11 compositor can replace this one **behind
the same seam** — the CPU master buffer stays the contract.
- **Branding flash is composited by the output path too** (it's on the live output, per Monetization),
passed in as a pre-rendered `VideoFrame?` — the compositor core stays pure byte-math, no WPF. Likely a
bundled asset rather than runtime text rendering (deterministic, no font/layout risk).
- **Frame sources are injected** via a `Func<SceneElement, VideoFrame?>` resolver
(`SceneCompositor.Render(scene, frameFor, flashFrame, options)`) — the caller maps each element to
its frame (webcam → `DeviceId`, image → `AssetId` via `StaticPixelCache`, backdrop → `CaptureKey`),
so the compositor is pure, WPF-free, and hermetic to test. The capture managers wire into that
resolver in the encoder step, not the compositor step. The master buffer (the compositor's return
value) is the seam a future D3D11 compositor would honor identically.
## Design Principle
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
+222
View File
@@ -0,0 +1,222 @@
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
namespace ytLive.Tests;
/// <summary>
/// The output compositor (TASK 4 ship step 1): renders a scene into the encoder's
/// master frame, mirroring the XAML preview minus the editing chrome. The integration
/// test composites a full scene (backdrop + round webcam + images + mirror + border)
/// and asserts per-layer probe pixels; the vertical-tier test asserts the 9:16 crop
/// and upscale. Frames are pushed by the test — no capture managers involved.
/// </summary>
public class SceneCompositorTests
{
public static VideoFrame Solid(int w, int h, byte r, byte g, byte b)
{
var pixels = new byte[w * h * 4];
for (var i = 0; i < pixels.Length; i += 4)
{
pixels[i] = b;
pixels[i + 1] = g;
pixels[i + 2] = r;
pixels[i + 3] = 255;
}
return new VideoFrame(w, h, pixels);
}
/// <summary>Left half = leftColor, right half = rightColor.</summary>
private static VideoFrame Split(int w, int h, byte lr, byte lg, byte lb, byte rr, byte rg, byte rb)
{
var pixels = new byte[w * h * 4];
var half = w / 2;
for (var y = 0; y < h; y++)
{
for (var x = 0; x < w; x++)
{
var i = (y * w + x) * 4;
pixels[i] = x < half ? lb : rb;
pixels[i + 1] = x < half ? lg : rg;
pixels[i + 2] = x < half ? lr : rr;
pixels[i + 3] = 255;
}
}
return new VideoFrame(w, h, pixels);
}
private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
{
var i = (y * frame.Width + x) * 4;
var br = frame.BgraPixels[i + 2];
var bg = frame.BgraPixels[i + 1];
var bb = frame.BgraPixels[i];
Assert.True(
Math.Abs(br - r) <= 2 && Math.Abs(bg - g) <= 2 && Math.Abs(bb - b) <= 2,
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
}
[Fact]
public void Composite_FullScene_MasterPixels()
{
var red = Solid(1920, 1080, 255, 0, 0); // backdrop
var green = Solid(1280, 720, 0, 255, 0); // webcam
var split = Split(100, 100, 0, 255, 255, 255, 0, 255); // image: left cyan, right magenta
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
var roundWebcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
var imageA = new Source { Type = SourceType.Image, X = 1200, Y = 600, Width = 200, Height = 200 };
var imageB = new Source
{
Type = SourceType.Image, X = 500, Y = 700, Width = 100, Height = 100, IsMirrored = true,
BorderColor = "#ffffff", BorderOpacity = 1, BorderWidth = 4,
};
var scene = new Scene { Name = "Live" };
scene.Elements.Add(backdrop);
scene.Elements.Add(roundWebcam);
scene.Elements.Add(imageA);
scene.Elements.Add(imageB);
VideoFrame? FrameFor(SceneElement e) => e switch
{
WebcamSceneConfig => green,
Source { IsBackdrop: true } => red,
Source { Type: SourceType.Image } => split,
_ => null,
};
var options = new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
OutputWidth = 1920, OutputHeight = 1080,
};
var output = new SceneCompositor().Render(scene, FrameFor, null, options);
Assert.Equal(1920, output.Width);
Assert.Equal(1080, output.Height);
// backdrop at the frame corner
AssertColor(output, 0, 0, 255, 0, 0);
// round webcam: center is the (square-cropped) webcam feed
AssertColor(output, 250, 250, 0, 255, 0);
// round webcam: element-square corner is OUTSIDE the circle -> backdrop shows
AssertColor(output, 101, 101, 255, 0, 0);
// imageA (unmirrored): left half cyan, right half magenta
AssertColor(output, 1220, 700, 0, 255, 255);
AssertColor(output, 1380, 700, 255, 0, 255);
// imageB (mirrored): halves swap — element left shows the source's right (magenta)
AssertColor(output, 520, 750, 255, 0, 255);
AssertColor(output, 580, 750, 0, 255, 255);
// imageB border: white ring at the top edge (drawn over the content)
AssertColor(output, 550, 700, 255, 255, 255);
}
[Fact]
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
{
var red = Solid(1920, 1080, 255, 0, 0);
var green = Solid(1280, 720, 0, 255, 0);
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
var roundWebcam = new WebcamSceneConfig { X = 656, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
var scene = new Scene { Name = "Live" };
scene.Elements.Add(backdrop);
scene.Elements.Add(roundWebcam);
VideoFrame? FrameFor(SceneElement e) => e switch
{
WebcamSceneConfig => green,
Source { IsBackdrop: true } => red,
_ => null,
};
var options = new CompositorOptions
{
SourceRectX = 656, SourceRectY = 0, SourceRectWidth = 607, SourceRectHeight = 1080,
OutputWidth = 1080, OutputHeight = 1920,
};
var output = new SceneCompositor().Render(scene, FrameFor, null, options);
Assert.Equal(1080, output.Width);
Assert.Equal(1920, output.Height);
// top-left of the crop is pure backdrop (webcam starts at crop y=100, inset from the corner)
AssertColor(output, 0, 0, 255, 0, 0);
// the webcam center (crop 150,250) scales to output ~(267,444) and stays green
AssertColor(output, 267, 444, 0, 255, 0);
// far from the webcam, still backdrop
AssertColor(output, 978, 1688, 255, 0, 0);
}
[Fact]
public void Composite_WithFlash_BlendsOverContent()
{
var red = Solid(1920, 1080, 255, 0, 0);
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
var scene = new Scene { Name = "Live" };
scene.Elements.Add(backdrop);
// flash: a semi-transparent white pixel at the center of a master-sized frame
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
var ci = (1080 / 2 * 1920 + 1920 / 2) * 4;
flash.BgraPixels[ci] = 255;
flash.BgraPixels[ci + 1] = 255;
flash.BgraPixels[ci + 2] = 255;
flash.BgraPixels[ci + 3] = 64; // ~25% alpha
var options = new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
OutputWidth = 1920, OutputHeight = 1080,
};
var output = new SceneCompositor().Render(scene, _ => red, flash, options);
// red lightened by 25% white: ~(255, 63, 63)
AssertColor(output, 960, 540, 255, 64, 64);
// untouched corner stays pure red
AssertColor(output, 0, 0, 255, 0, 0);
}
}
public class StretchMathTests
{
[Fact]
public void UniformToFill_SameAspect_Is_Exact_Fit_With_No_Offset()
{
var (scale, ox, oy) = StretchMath.UniformToFill(400, 300, 800, 600);
Assert.Equal(0.5f, scale, 3);
Assert.Equal(0f, ox, 3);
Assert.Equal(0f, oy, 3);
}
[Fact]
public void UniformToFill_WiderSource_Crops_And_Centers_Vertically()
{
var (scale, ox, oy) = StretchMath.UniformToFill(400, 200, 800, 600);
Assert.Equal(0.5f, scale, 3);
Assert.Equal(0f, ox, 3);
Assert.Equal(-50f, oy, 3); // drawn 400x300 into 400x200 -> 50px crop top and bottom
}
[Fact]
public void BilinearScale_SameSize_ReturnsTheInput()
{
var src = SceneCompositorTests.Solid(4, 4, 10, 20, 30);
Assert.Same(src, StretchMath.BilinearScale(src, 4, 4));
}
[Fact]
public void BilinearScale_Downscales_To_Target_Size()
{
var src = SceneCompositorTests.Solid(16, 16, 0, 255, 0);
var scaled = StretchMath.BilinearScale(src, 8, 8);
Assert.Equal(8, scaled.Width);
Assert.Equal(8, scaled.Height);
var i = 0;
Assert.Equal(255, scaled.BgraPixels[i + 1]); // solid green survives the scale
}
}
+3 -3
View File
@@ -19,15 +19,15 @@ public class YouTubeAuthServiceTests
_channelResponse = channelResponse;
}
protected override async Task<HttpResponseMessage> SendAsync(
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var isToken = request.RequestUri!.PathAndQuery.Contains("/token");
var body = isToken ? _tokenResponse : _channelResponse;
return new HttpResponseMessage(HttpStatusCode.OK)
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
});
}
}