Compare commits
2 Commits
b00a4cbd5e
...
8c7938aca0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c7938aca0 | |||
| 18a21010bb |
@@ -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}"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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('#');
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 `(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<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**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user