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"), - }; + }); } }