Compare commits

...

2 Commits

Author SHA1 Message Date
gramps 8c7938aca0 FFmpeg locator (TASK 4 ship step 2) + licensing: notices, About, LGPL-shared pin
IFfmpegLocator seam that resolves ffmpeg.exe (PATH -> cache -> pinned download),
plus the licensing compliance that makes a paid GA product defensible.

NOTE FOR USERS: this change shows NO difference in the app's behavior except a
new top-bar "About" button (opens THIRD-PARTY-NOTICES.txt). It is scaffolding
for the encoder/streaming work.

- Services/Encoder/: IFfmpegLocator + FfmpegLocator. Pin is BtbN lgpl-shared
  autobuild-2026-08-09-13-03 (NOT gyan.dev/GPL or static: LGPLv2.1 §6 static
  relink material avoided by dynamic linking); extracts ffmpeg.exe + libav*.dll
  via a staging dir so a crash never leaves a partial cache
- THIRD-PARTY-NOTICES.txt: LGPL/BSD/MIT notices + source offer, copied to the
  build output, surfaced by the About button; v1 gate = bundle full license
  texts (TASK 4 requirement 9)
- ai.md "Licensing - do not violate" guardrails (never GPL/nonfree/static/latest-
  tag, never link FFmpeg in, never drop notices); TASKS.md + Services/index.md
  updated
- Tests: FfmpegLocatorTests - hermetic decision-ladder integration test + shared-
  build DLL extraction + edge cases; docs updated (78 tests passing, 0 warnings)
2026-08-10 11:02:26 -07:00
gramps 18a21010bb 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)
2026-08-10 10:15:58 -07:00
18 changed files with 1204 additions and 11 deletions
+2
View File
@@ -105,6 +105,8 @@
<!-- Single three-state action button --> <!-- Single three-state action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="About" Style="{StaticResource YtButtonSecondary}"
Click="AboutButton_Click" Margin="0,0,12,0" VerticalAlignment="Center"/>
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True" <Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
Margin="0,0,8,0" VerticalAlignment="Center" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountDisplayName}" ToolTip="{Binding AccountDisplayName}"
+17
View File
@@ -1,5 +1,7 @@
using System.Collections; using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
@@ -123,6 +125,21 @@ public partial class MainWindow : Window
} }
} }
private void AboutButton_Click(object sender, RoutedEventArgs e)
{
// LGPL/BSD/MIT notices ship next to the exe; open in the OS text viewer.
var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
if (!File.Exists(path)) return;
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
AppLog.Write(ex, "About: failed to open THIRD-PARTY-NOTICES.txt");
}
}
private void AddSourceButton_Click(object sender, RoutedEventArgs e) private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{ {
if (sender is Button { ContextMenu: { } menu } button) if (sender is Button { ContextMenu: { } menu } button)
+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; r = g = b = 0;
var hex = BorderColor.Trim().TrimStart('#'); 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;
}
+129
View File
@@ -0,0 +1,129 @@
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using ytLive.Helpers;
namespace ytLive.Services.Encoder;
/// <summary>
/// The default <see cref="IFfmpegLocator"/>: probe PATH first (the user's own
/// install wins), then the cache in <c>%APPDATA%\ytLlive\tools</c>, then pull the
/// pinned BtbN **lgpl-shared** build (TASK 4 ship step 2). The shared variant is a
/// deliberate licensing choice: dynamic linking means LGPL compliance is "license
/// text + source offer", with no static-relink (LGPL §6) material required. The
/// shared zip puts <c>ffmpeg.exe</c> plus the <c>libav*.dll</c> family in <c>bin/</c>,
/// so both are extracted — Windows resolves the DLLs from the exe's own directory.
/// Search dirs, tools dir, and the downloader are constructor-injected so tests
/// fake the network and stay on a temp directory.
/// </summary>
public sealed class FfmpegLocator : IFfmpegLocator
{
public const string FileName = "ffmpeg.exe";
/// <summary>Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md).</summary>
public const string PinnedUrl =
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip";
private readonly string[] _searchDirs;
private readonly string _toolsDir;
private readonly Func<string, CancellationToken, Task<byte[]>> _downloader;
public FfmpegLocator(
string[]? searchDirs = null,
string? toolsDir = null,
Func<string, CancellationToken, Task<byte[]>>? downloader = null)
{
_searchDirs = searchDirs ?? ParsePath();
_toolsDir = toolsDir ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "tools");
_downloader = downloader ?? DefaultDownload;
}
public async Task<string> LocateAsync(CancellationToken cancellationToken = default)
{
foreach (var dir in _searchDirs)
{
if (string.IsNullOrEmpty(dir)) continue;
var candidate = Path.Combine(dir, FileName);
if (File.Exists(candidate)) return candidate;
}
var cached = Path.Combine(_toolsDir, FileName);
if (File.Exists(cached) && new FileInfo(cached).Length > 0) return cached;
try
{
var zip = await _downloader(PinnedUrl, cancellationToken).ConfigureAwait(false);
if (zip.Length == 0)
throw new IOException($"FFmpeg download from {PinnedUrl} returned an empty payload.");
Directory.CreateDirectory(_toolsDir);
ExtractBinaries(zip, _toolsDir);
return cached;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException)
{
AppLog.Write(ex, "FFmpeg locator: download/extract failed");
throw;
}
}
/// <summary>
/// Extract <c>ffmpeg.exe</c> and every <c>*.dll</c> into <c>toolsDir</c> via a
/// staging directory, so a failed extract never leaves a partially-populated
/// cache behind (the previous good cache stays until every move succeeds).
/// </summary>
private static void ExtractBinaries(byte[] zip, string toolsDir)
{
var staging = toolsDir + ".stage";
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
Directory.CreateDirectory(staging);
try
{
using var stream = new MemoryStream(zip, writable: false);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
var exe = archive.Entries.FirstOrDefault(
e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}.");
ExtractOne(exe, Path.Combine(staging, FileName));
foreach (var entry in archive.Entries)
{
if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
ExtractOne(entry, Path.Combine(staging, Path.GetFileName(entry.FullName)));
}
foreach (var file in Directory.GetFiles(staging))
File.Move(file, Path.Combine(toolsDir, Path.GetFileName(file)), overwrite: true);
}
finally
{
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
}
}
private static void ExtractOne(ZipArchiveEntry entry, string destination)
{
var temp = destination + ".tmp";
try
{
using (var source = entry.Open())
using (var target = File.Create(temp))
source.CopyTo(target);
File.Move(temp, destination);
}
finally
{
if (File.Exists(temp)) File.Delete(temp);
}
}
private static async Task<byte[]> DefaultDownload(string url, CancellationToken ct)
{
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
return await http.GetByteArrayAsync(url, ct).ConfigureAwait(false);
}
private static string[] ParsePath()
{
var raw = Environment.GetEnvironmentVariable("PATH") ?? "";
return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace ytLive.Services.Encoder;
/// <summary>
/// Resolves an absolute path to a usable <c>ffmpeg.exe</c>, downloading it on
/// first use if neither the user's PATH nor the local cache provides one — the
/// encoder's one external dependency is never shipped in the repo (TASK 4 ship
/// step 2; see TASKS.md). Seam so the encoder step and the tests can fake it.
/// </summary>
public interface IFfmpegLocator
{
/// <summary>
/// Returns the path to <c>ffmpeg.exe</c>: the first PATH candidate that
/// exists, else the cached copy, else a freshly downloaded one (ffmpeg.exe +
/// its libav DLLs extracted from the pinned BtbN LGPL-shared zip into the
/// tools directory).
/// </summary>
/// <exception cref="IOException">The download/extract produced no usable
/// binary (offline, expired pin, corrupt archive) — recoverable, logged.</exception>
Task<string> LocateAsync(CancellationToken cancellationToken = default);
}
+6
View File
@@ -27,6 +27,12 @@ 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` | | `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 | | `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) | | `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`) |
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md). (no DI container yet). Models in [`Models/index.md`](../Models/index.md).
+133 -3
View File
@@ -169,8 +169,8 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
### Requirements: ### 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 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. **License guardrails (never violate — see `ai.md` → "Licensing — do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen.
2. **RTMP push** — FFmpeg subprocess or native RTMP library, to the cached reusable stream's ingestion URL 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 (**BtbN LGPL win64 static** zip, ~75 MB — gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract just `ffmpeg.exe` from the zip) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**: 3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
- 720p30 @ 6 Mbps - 720p30 @ 6 Mbps
- 720p60 @ 6 Mbps - 720p60 @ 6 Mbps
@@ -191,8 +191,138 @@ 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` 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) 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 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.
8. **Private-only go live until v1 (reputation guard, decided 2026-08-10)** — until the v1 release, go-live is **locked to private streams only** so a software error can never publish something public/unlisted that damages the creator's reputation. RTMP push itself has no privacy — privacy lives on the YouTube **live broadcast object**, which this app already controls via its OAuth API calls. So the lock is purely API-side: the Go Live flow always creates/updates the broadcast with `privacyStatus = "private"` and a guard **refuses** to set anything else (same spirit as the Live-only backdrop policy). The UI shows a clear "PRIVATE" badge next to the stream state so the creator always knows who can see them. Enforcement must be verifiable in the auth-service tests (fake the broadcast-insert/update call, assert `privacyStatus` is forced to private).
9. **v1 release gate: bundle the full license texts (decided 2026-08-10)**`THIRD-PARTY-NOTICES.txt` currently links the canonical license texts rather than embedding them. At the **v1 (GA) release**, the full texts of every license it names (LGPL v2.1+, BSD-2-Clause, MIT, Apache-2.0) MUST be bundled alongside it (shipped in the app output, e.g. a `licenses/` folder next to the notices file, still reachable from the About screen). This is a **release blocker for v1, not a task to queue early** — do it in the release pass. The repo should treat this like the private-only go-live gate: a checkbox that cannot silently lapse.
### Status: Not started ### Status: 🔶 In progress — **ship step 1 (the output compositor) SHIPPED** (2026-08-10); **ship step 2 (the FFmpeg locator) 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**.
#### Ship step 2 — FFmpeg locator (the encoder's binary)
**Goal:** resolve a usable `ffmpeg.exe` on demand (the encoder's one external dependency), never shipping
a binary in the repo. Returns an absolute path; downloads only when neither PATH nor the local cache
provides one.
**Decisions (locked 2026-08-10):**
- **BtbN LGPL-shared win64 build** — not gyan.dev (gyan's "essentials" is GPLv3 and ships libx264, which
violates requirement 1's license posture) and **not the static lgpl build**: LGPLv2.1 §6 wants
relinkable object files for static linking, but the **shared** (dynamic-DLL) variant sidesteps that —
compliance is "license text + source offer + unmodified binaries" (see `THIRD-PARTY-NOTICES.txt` and
`ai.md` → Licensing). Drops libx264/libx265 while keeping NVENC/QSV/AMF, libopenh264 (the LGPL-legal
H.264 software fallback) and native AAC — exactly the requirement-1 encoder profile.
- **Pinned URL** — `https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip`
(~75 MB zip — earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention
keeps the last 14 daily builds + each month-end build for 2 years, so a cold cache after retention
expiry 404s — a logged, recoverable failure (the seam throws; the encoder step surfaces it). Once
cached, the URL is never touched again. The pin is a single `const`, bumpable in one place — and must
always stay on the **shared** variant (never `gpl`, `nonfree`, or static; see ai.md Licensing).
- **Check-then-pull order** — (1) PATH probe (the user's own install wins), (2) cached
`%APPDATA%\ytLlive\tools\ffmpeg.exe`, (3) download + extract. Extract `ffmpeg.exe` **plus the
`libav*.dll` family** (the shared build's bin/ folder; Windows resolves the DLLs from the exe's own
directory) into a staging dir then move into place — a crash never leaves a corrupt or partial cache.
- **Seam** — `IFfmpegLocator.LocateAsync(CancellationToken)`: search dirs, tools dir, and the downloader
(`Func<string, CancellationToken, Task<byte[]>>`) are constructor-injected with production defaults, so
tests fake the network (feeding a real in-memory zip) and never touch disk outside a temp dir.
**New files (all in `Services/Encoder/`):**
- `IFfmpegLocator.cs` — the seam.
- `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`.
- `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build
output and surfaced via the top-bar **About** button (`MainWindow` code-behind, opens the file in the
OS viewer).
**Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and
a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without
downloading, cache hit skips the network, cold cache downloads → extracts → `ffmpeg.exe` lands in the
tools dir, and a second call serves the cache (downloader invoked exactly once). Focused unit tests:
**shared-build DLLs extract alongside the exe**, empty zip throws, missing entry throws, empty download
throws, downloader failure propagates, zero-byte cache is refreshed.
**Same-PR housekeeping:** requirement 2's stale binary facts corrected in this plan (~30 MB → ~75 MB zip;
"gyan.dev/BtB N" → BtbN LGPL-shared only, with the why); the "never do" licensing guardrails recorded in
`ai.md` so the reasoning survives.
**Out of scope (later ship steps):** the FFmpeg subprocess encoder (frames in via stdin, stderr health
parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health stats.
**Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the
**lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output and is surfaced by a new
top-bar **About** button; the "never do" licensing guardrails are recorded in `ai.md` — build **0 warnings**.
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
--- ---
+78
View File
@@ -0,0 +1,78 @@
ytLlive — Third-Party Notices
================================
ytLlive is a paid, closed-source product. This file lists every third-party
component the product distributes or downloads, its license, and where to get
its source, so the LGPL/BSD/MIT obligations are met. Distribution obligations
are NOT optional: they attach because this product ships or automates the
download of these components.
If this file changes, update it here AND in the app's About screen (it opens
this file). See TASKS.md (TASK 4) and ai.md ("Licensing — do not violate") for
the guardrails — the "never do" list is there on purpose.
--------------------------------------------------------------------------------
1. FFmpeg (dynamic libraries + ffmpeg.exe, LGPL v2.1+)
Copyright (c) 2000-2026 the FFmpeg developers
License: GNU Lesser General Public License v2.1 or later
Home: https://ffmpeg.org/
Source: https://git.ffmpeg.org/ffmpeg.git
Used as: the streaming encoder/RTMP subprocess. This product distributes the
UNMODIFIED binaries; it never links FFmpeg into its own code (it is
launched as a separate process fed raw frames over a pipe).
Why LGPL (not GPL): a GPL build (e.g. gyan.dev, or BtbN's "gpl" variant)
would contaminate this proprietary product. Do NOT use one.
Why "shared" (not static): LGPL v2.1 §6 requires "relinkable" materials for
statically-linked libraries. The shared build links dynamically, so
the user can replace the DLLs — compliance is this notice plus the
source offer below, with no relink material required.
Compliance supplied by this product:
- The license text: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
- The corresponding source / written offer to obtain it:
FFmpeg source https://ffmpeg.org/download.html
Exact binary https://github.com/BtbN/FFmpeg-Builds
Build tag: autobuild-2026-08-09-13-03 (variant lgpl-shared)
- The binaries are unmodified and the LGPL notices therein are intact.
2. BtbN FFmpeg-Builds (the exact binary this product downloads)
License: MIT (build scripts + repository) — the produced binaries are
covered by FFmpeg's LGPL (item 1).
Home: https://github.com/BtbN/FFmpeg-Builds
3. OpenH264 (libopenh264 — the H.264 software fallback encoder)
Copyright (c) 2010-2026 Cisco Systems, Inc. (and contributors)
License: BSD 2-Clause + Cisco's H.264 patent grant
Home: https://www.openh264.org/
Note: Cisco grants the patent license for its own H.264 implementation;
it ships inside the FFmpeg build above (LGPL obligations of item 1
apply to the library; the BSD terms apply to Cisco's code).
4. SQLite (bundled via SQLitePCLRaw's e_sqlite3 native bundle)
License: public domain (no rights reserved)
Home: https://www.sqlite.org/
5. Microsoft.Data.Sqlite (.NET data provider, statically linked into this app)
Copyright (c) .NET Foundation and contributors
License: MIT
Home: https://github.com/dotnet/efcore
6. SQLitePCLRaw (raw SQLite bindings + bundles)
Copyright (c) 2012-2026 Eric Sink and contributors
License: Apache-2.0
Home: https://github.com/ericstj/SQLitePCLRaw
7. .NET runtime / WPF / Windows SDK projections, incl.
System.Security.Cryptography.ProtectedData
Copyright (c) .NET Foundation and contributors
License: MIT
Home: https://github.com/dotnet/
--------------------------------------------------------------------------------
MISCELLANEOUS
- The full text of every license named above is available at the linked
canonical locations. The v1 (GA) release MUST additionally bundle the full
license texts alongside this file (a `licenses/` folder beside it, still
reachable from the About screen) — TASK 4 requirement 9, a release blocker.
- No warranty is expressed or implied for any third-party component.
+2 -1
View File
@@ -321,7 +321,8 @@ public class MainViewModel : ViewModelBase
public string MicMuteText => MicMuted ? "Unmute" : "Mute"; 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 public string? MicSourceName
{ {
get => _micSourceName; get => _micSourceName;
+77 -3
View File
@@ -59,8 +59,12 @@ ScreenCaptureManager refcount + shared-bitmap + coalescing
(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackdropTests (EnsureBackdrop (fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackdropTests (EnsureBackdrop
insert/idempotent/heal + HasBackdrop gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests insert/idempotent/heal + HasBackdrop gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests
(the five canonical scenes, Live-only backdrop policy, EnforceBackdropPolicy), WebcamSafeguardTests (the five canonical scenes, Live-only backdrop policy, EnforceBackdropPolicy), WebcamSafeguardTests
(the per-scene size clamp incl. the Chat half-screen-area cap) (the per-scene size clamp incl. the Chat half-screen-area cap), SceneCompositorTests (the full-scene
65 passing. composite integration test: backdrop + round webcam + mirrored/bordered images + flash; the vertical
tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear), FfmpegLocatorTests
(the PATH → cache → download decision ladder with a fake downloader serving a real in-memory zip; shared
build DLL extraction) —
78 passing.
### Real-MainWindow tests MUST be hermetic (DB pollution bug) ### Real-MainWindow tests MUST be hermetic (DB pollution bug)
@@ -110,7 +114,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`) - `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) - 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 - `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 SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED** — full plan in `TASKS.md`; the encoder subprocess + RTMP push, audio capture, and the frame-pipeline wiring follow (each its own PR)
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead - `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
### Screen backdrop capture (TASK 3 ship task #1) ### Screen backdrop capture (TASK 3 ship task #1)
@@ -282,6 +286,76 @@ instead of a normal draggable source.
- **Background removal = milestone 2** — ONNX Runtime + DirectML (CPU fallback), MediaPipe Selfie - **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. 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.
### FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10, plan in TASKS.md)
The encoder's one external dependency is `ffmpeg.exe`; it's never shipped in the repo. `IFfmpegLocator`
resolves an absolute path on demand: **PATH probe first** (the user's own install wins — their choice,
their responsibility), then the cache (`%APPDATA%\ytLlive\tools\ffmpeg.exe`), then a **pinned** BtbN
LGPL-**shared** win64 zip (~75 MB) from which `ffmpeg.exe` **and the `libav*.dll` family** are extracted
(staged temp-write + move so a crash never corrupts the cache; Windows resolves the DLLs from the exe's
own directory). BtbN LGPL-shared (not gyan.dev, not static): it drops GPL-only libx264/x265 while keeping
NVENC/QSV/AMF + libopenh264 + native AAC, and dynamic linking means LGPL compliance is "license text +
source offer" with no static-relink (§6) material — see the Licensing guardrails below. The pin is a
dated autobuild tag (immutable); BtbN retention keeps the last 14 daily + each month-end for 2 years, so
a cold cache can outlive the pin → the seam throws a clear, logged error (recoverable; the pin is one
const). Constructor-injected search dirs / tools dir / downloader (`Func<string, CancellationToken,
Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the
encoder step (not yet — this PR ships the seam + impl + tests only).
### Licensing — do not violate (GA = paid product; see `THIRD-PARTY-NOTICES.txt`)
This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT
guardrails below — written down so a future "quick fix" never reintroduces a GPL binary. **NEVER:**
- **Use a GPL FFmpeg build** — gyan.dev's builds are GPLv3 and ship libx264; BtbN's `gpl` variant is
GPL too. GPL in a distributed paid product is the #1 lawsuit risk. Only BtbN `lgpl` / `lgpl-shared`
builds are allowed.
- **Distribute the static lgpl build** — LGPLv2.1 §6 wants relinkable object files for static linking.
The **shared** (dynamic-DLL) build sidesteps that: compliance is "license text + source offer +
unmodified binaries". The pin is `lgpl-shared`; when the pin is refreshed, keep the shared variant.
- **Use BtbN's `nonfree` variant** — it adds fdk-aac (Fraunhofer code licensing). The native FFmpeg AAC
encoder is fine (no Fraunhofer code) but grants no AAC patent license — accepted low-risk posture for
RTMP→YouTube, since encoder vendors cover their implementations (Cisco OpenH264, NVIDIA NVENC, Intel
QSV, AMD AMF).
- **Link FFmpeg into the app** — it stays a separate subprocess fed frames over a pipe; that separation
keeps the app's own code out of LGPL reach.
- **Drop `THIRD-PARTY-NOTICES.txt`** from the shipped app or the About screen, or alter the FFmpeg
copyright/LGPL notices inside the downloaded binaries. Automating the download counts as distribution
— the obligations are not optional.
- **Pin to a moving target** — the `latest` BtbN release tag floats. Only immutable autobuild tags give
a reproducible source offer. Record the tag + variant beside the URL (TASKS.md) every time the pin moves.
- **Forget the v1 license-texts gate** — `THIRD-PARTY-NOTICES.txt` links the canonical license texts; at
**v1 (GA)** the full texts of every license it names MUST ship alongside it (TASK 4 requirement 9 is the
release blocker). Queued early is wrong; the release pass owns it.
## Design Principle ## Design Principle
> This software is so intuitive that even the most right-brained person can easily intuit and use it. > This software is so intuitive that even the most right-brained person can easily intuit and use it.
+169
View File
@@ -0,0 +1,169 @@
using System.IO;
using System.IO.Compression;
using Xunit;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The FFmpeg locator (TASK 4 ship step 2): resolves ffmpeg.exe by probing PATH
/// first, then the cache in the tools directory, then pulling the pinned BtbN
/// LGPL-shared zip. The integration test drives the full ladder against a temp
/// tools dir and a fake downloader that returns a real in-memory zip; the units
/// pin down the failure and edge cases. No network, no real binary.
/// </summary>
public class FfmpegLocatorTests
{
private static byte[] MakeZip(
string exePath = "ffmpeg-master-latest-win64-lgpl-shared/bin/ffmpeg.exe",
string[]? dllPaths = null)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(exePath);
using (var writer = new StreamWriter(entry.Open()))
writer.Write("dummy ffmpeg binary");
foreach (var dll in dllPaths ?? Array.Empty<string>())
{
var dllEntry = archive.CreateEntry(dll);
using var dllWriter = new StreamWriter(dllEntry.Open());
dllWriter.Write("dummy dll");
}
}
return ms.ToArray();
}
private static string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
private sealed class RecordingDownloader
{
public int Calls { get; private set; }
public byte[] Payload { get; set; } = MakeZip();
public Exception? Error { get; set; }
public Task<byte[]> DownloadAsync(string url, CancellationToken ct)
{
Calls++;
if (Error != null) throw Error;
return Task.FromResult(Payload);
}
}
[Fact]
public async Task Locate_Integration_FullDecisionLadder()
{
var toolsDir = TempDir();
try
{
// 1. PATH hit wins, downloader never invoked.
var pathDir = TempDir();
var pathExe = Path.Combine(pathDir, FfmpegLocator.FileName);
File.WriteAllText(pathExe, "user's ffmpeg");
var downloader = new RecordingDownloader();
var fromPath = new FfmpegLocator([pathDir], toolsDir, downloader.DownloadAsync);
Assert.Equal(pathExe, await fromPath.LocateAsync());
Assert.Equal(0, downloader.Calls);
// 2. Cache hit skips the network.
var cached = Path.Combine(toolsDir, FfmpegLocator.FileName);
Directory.CreateDirectory(toolsDir);
File.WriteAllText(cached, "cached ffmpeg");
var fromCache = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
Assert.Equal(cached, await fromCache.LocateAsync());
Assert.Equal(0, downloader.Calls);
// 3. Cold cache downloads exactly once, extracts ffmpeg.exe, and the
// second call serves the cache without re-downloading.
File.Delete(cached);
var coldTools = TempDir();
var cold = new FfmpegLocator([], coldTools, downloader.DownloadAsync);
var resolved = await cold.LocateAsync();
Assert.Equal(Path.Combine(coldTools, FfmpegLocator.FileName), resolved);
Assert.Equal(1, downloader.Calls);
Assert.True(new FileInfo(resolved).Length > 0);
Assert.Equal(resolved, await cold.LocateAsync());
Assert.Equal(1, downloader.Calls);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_ZeroByteCache_IsRefreshed()
{
var toolsDir = TempDir();
try
{
File.WriteAllText(Path.Combine(toolsDir, FfmpegLocator.FileName), "");
var downloader = new RecordingDownloader();
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
var resolved = await locator.LocateAsync();
Assert.Equal(1, downloader.Calls);
Assert.True(new FileInfo(resolved).Length > 0);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_SharedBuild_ExtractsDllsAlongsideExe()
{
var toolsDir = TempDir();
try
{
var downloader = new RecordingDownloader
{
Payload = MakeZip(dllPaths:
[
"ffmpeg-master-latest-win64-lgpl-shared/bin/avcodec-61.dll",
"ffmpeg-master-latest-win64-lgpl-shared/bin/avformat-61.dll",
])
};
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
var resolved = await locator.LocateAsync();
Assert.True(File.Exists(Path.Combine(toolsDir, "avcodec-61.dll")));
Assert.True(File.Exists(Path.Combine(toolsDir, "avformat-61.dll")));
Assert.Equal(1, downloader.Calls);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_EmptyPayload_Throws()
{
var downloader = new RecordingDownloader { Payload = Array.Empty<byte>() };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
Assert.Equal(1, downloader.Calls);
}
[Fact]
public async Task Locate_ZipWithoutFfmpegEntry_Throws()
{
var downloader = new RecordingDownloader { Payload = MakeZip(exePath: "readme.txt") };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<InvalidDataException>(() => locator.LocateAsync());
}
[Fact]
public async Task Locate_DownloaderFailure_Propagates()
{
var downloader = new RecordingDownloader { Error = new HttpRequestException("offline") };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<HttpRequestException>(() => locator.LocateAsync());
}
}
+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; _channelResponse = channelResponse;
} }
protected override async Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
{ {
var isToken = request.RequestUri!.PathAndQuery.Contains("/token"); var isToken = request.RequestUri!.PathAndQuery.Contains("/token");
var body = isToken ? _tokenResponse : _channelResponse; 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"), Content = new StringContent(body, Encoding.UTF8, "application/json"),
}; });
} }
} }
+4
View File
@@ -24,6 +24,10 @@
<Resource Include="Assets\llama-logo-icon.png"/> <Resource Include="Assets\llama-logo-icon.png"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup> <ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ytLive.Tests</_Parameter1> <_Parameter1>ytLive.Tests</_Parameter1>