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
+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;
}
}
}