diff --git a/Models/SceneElement.cs b/Models/SceneElement.cs
index 7d94225..2e0d686 100644
--- a/Models/SceneElement.cs
+++ b/Models/SceneElement.cs
@@ -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('#');
diff --git a/Services/Compositor/CompositorOptions.cs b/Services/Compositor/CompositorOptions.cs
new file mode 100644
index 0000000..b20df8c
--- /dev/null
+++ b/Services/Compositor/CompositorOptions.cs
@@ -0,0 +1,19 @@
+namespace ytLive.Services.Compositor;
+
+///
+/// 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 OutputRectX can be 656.5, so the caller
+/// rounds before building these options.
+///
+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; }
+}
diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs
new file mode 100644
index 0000000..735c27e
--- /dev/null
+++ b/Services/Compositor/SceneCompositor.cs
@@ -0,0 +1,198 @@
+using ytLive.Models;
+
+namespace ytLive.Services.Compositor;
+
+///
+/// The output compositor: renders a scene into the encoder's master frame (tightly-packed
+/// BGRA8 ) 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 resolver
+/// (the caller maps webcam → DeviceId, images → AssetId, backdrop → CaptureKey), keeping
+/// the compositor pure and free of WPF and of the capture managers.
+///
+public sealed class SceneCompositor
+{
+ ///
+ /// Composite into the tier's output frame. Layer order (back →
+ /// front): live backdrop (the scene's IsBackdrop source) → background image →
+ /// visible elements (z-order = Elements order, mirroring the XAML DataTemplate) →
+ /// branding flash. Transparent regions read opaque black.
+ ///
+ public VideoFrame Render(
+ Scene scene,
+ Func 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().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().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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+ }
+
+ /// Centered OBS-style border stroke: rect ring (Traditional) or circle ring (Round).
+ 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);
+ }
+ }
+ }
+
+ /// 1:1 copy of the master-sized branding flash, cropped to the active source rect.
+ 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);
+ }
+ }
+ }
+
+ /// Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.
+ 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);
+ }
+}
diff --git a/Services/Compositor/StaticPixelCache.cs b/Services/Compositor/StaticPixelCache.cs
new file mode 100644
index 0000000..e98f623
--- /dev/null
+++ b/Services/Compositor/StaticPixelCache.cs
@@ -0,0 +1,48 @@
+using System.IO;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace ytLive.Services.Compositor;
+
+///
+/// Decodes a layout asset's bytes into a tightly-packed BGRA8
+/// once per content-hash asset id. The preview uses the WPF BitmapImage in
+/// ImageCache; the output path needs raw pixels, so assets decode here instead.
+///
+public static class StaticPixelCache
+{
+ private static readonly Dictionary 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;
+ }
+ }
+}
diff --git a/Services/Compositor/StretchMath.cs b/Services/Compositor/StretchMath.cs
new file mode 100644
index 0000000..910732e
--- /dev/null
+++ b/Services/Compositor/StretchMath.cs
@@ -0,0 +1,76 @@
+namespace ytLive.Services.Compositor;
+
+///
+/// Pure pixel math shared by the compositor: the WPF "UniformToFill" cover-crop
+/// (what the preview's Stretch="UniformToFill" does) and a clamped bilinear
+/// sample/scale. Pure and deterministic — the unit-tested half of the compositor.
+///
+public static class StretchMath
+{
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ /// Clamped bilinear scale into a new tightly-packed BGRA8 frame; returns the input unchanged when the sizes already match.
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+}
diff --git a/Services/index.md b/Services/index.md
index ac55f9d..75c425d 100644
--- a/Services/index.md
+++ b/Services/index.md
@@ -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:` / `window:` / `picker:` 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` 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).
diff --git a/TASKS.md b/TASKS.md
index 05c623c..07d25c4 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -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 `(1920−607)/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, 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**.
---
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index aaf0a42..9d1289b 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -321,7 +321,8 @@ public class MainViewModel : ViewModelBase
public string MicMuteText => MicMuted ? "Unmute" : "Mute";
- /// Name of the picked voice source, shown under the meter on line 2.
+ /// 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.
public string? MicSourceName
{
get => _micSourceName;
diff --git a/ai.md b/ai.md
index e8b06da..8399be8 100644
--- a/ai.md
+++ b/ai.md
@@ -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` 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.
diff --git a/ytLive.Tests/SceneCompositorTests.cs b/ytLive.Tests/SceneCompositorTests.cs
new file mode 100644
index 0000000..bd39bd4
--- /dev/null
+++ b/ytLive.Tests/SceneCompositorTests.cs
@@ -0,0 +1,222 @@
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.Services.Compositor;
+
+namespace ytLive.Tests;
+
+///
+/// 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.
+///
+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);
+ }
+
+ /// Left half = leftColor, right half = rightColor.
+ 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
+ }
+}
diff --git a/ytLive.Tests/YouTubeAuthServiceTests.cs b/ytLive.Tests/YouTubeAuthServiceTests.cs
index 7e31f66..aa1e88c 100644
--- a/ytLive.Tests/YouTubeAuthServiceTests.cs
+++ b/ytLive.Tests/YouTubeAuthServiceTests.cs
@@ -19,15 +19,15 @@ public class YouTubeAuthServiceTests
_channelResponse = channelResponse;
}
- protected override async Task SendAsync(
+ protected override Task 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"),
- };
+ });
}
}