TASK 51 slice 4: camera-path hookup — CameraManager renders per-scene effect variants (one segmentation per raw frame on a latest-frame-drop worker, raw path untouched), VM declares/adopts variants per config; separable upsampling cuts Remove 46->3 ms, Blur 80->18 ms (Debug, 720p) (Unit: bg-effects hookup)

This commit is contained in:
2026-10-09 10:27:38 -07:00
parent 55210b9a61
commit de22983b36
10 changed files with 524 additions and 78 deletions
@@ -90,26 +90,36 @@ public sealed class BackgroundEffectProcessor
var dst = new byte[src.Length];
Buffer.BlockCopy(src, 0, dst, 0, src.Length);
// Edge feather: map the mask's [0.5 - w, 0.5 + w] band to alpha 0..1.
var w = (float)(0.05 + 0.45 * strength);
var lo = 0.5f - w;
// Edge feather: map the mask's [0.5 - w, 0.5 + w] band to alpha 0..1, as a 256-entry LUT
// over the quantized mask value (so the per-pixel work is a lookup + one multiply).
var w = 0.05 + 0.45 * strength;
var lo = 0.5 - w;
var span = 2 * w;
var alphaLut = new int[256];
for (var i = 0; i < 256; i++)
alphaLut[i] = (int)Math.Round(Math.Clamp((i / 255.0 - lo) / span, 0, 1) * 256);
var width = frame.Width;
var height = frame.Height;
var stride = frame.Stride;
for (var y = 0; y < height; y++)
var maskRows = UpsampleMaskRows(mask, width);
var (r0, r1, fy) = AxisLut(mask.Height, height);
Parallel.For(0, height, RowParallelism, y =>
{
var my = MaskCoord(y, height, mask.Height);
var row = y * stride;
var a0 = maskRows[r0[y]];
var a1 = maskRows[r1[y]];
var t = fy[y];
var row = y * stride + 3;
for (var x = 0; x < width; x++)
{
var m = SampleBilinear(mask, MaskCoord(x, width, mask.Width), my);
var a = Math.Clamp((m - lo) / span, 0f, 1f);
var p = row + x * 4 + 3;
dst[p] = (byte)(a * src[p] + 0.5f);
var m = a0[x] + (a1[x] - a0[x]) * t;
var q = (int)(m * 255f + 0.5f);
q = q < 0 ? 0 : q > 255 ? 255 : q;
var p = row + x * 4;
dst[p] = (byte)((src[p] * alphaLut[q] + 128) >> 8);
}
}
});
return new VideoFrame(width, height, dst) { Epoch = frame.Epoch, IsOpaque = false };
}
@@ -126,7 +136,7 @@ public sealed class BackgroundEffectProcessor
var sw = (width + f - 1) / f;
var sh = (height + f - 1) / f;
var small = new float[sw * sh * 3];
for (var sy = 0; sy < sh; sy++)
Parallel.For(0, sh, RowParallelism, sy =>
{
var y0 = sy * f;
var y1 = Math.Min(height, y0 + f);
@@ -148,7 +158,7 @@ public sealed class BackgroundEffectProcessor
var o = (sy * sw + sx) * 3;
small[o] = b / n; small[o + 1] = g / n; small[o + 2] = r / n;
}
}
});
// 2. Three separable box-blur passes ≈ a gaussian.
var radius = (int)Math.Round(MinBlurRadius + (MaxBlurRadius - MinBlurRadius) * strength);
@@ -159,34 +169,126 @@ public sealed class BackgroundEffectProcessor
BoxBlurVertical(scratch, small, sw, sh, radius);
}
// 3. Blend: person (mask) keeps the source pixel, background takes the upsampled blur.
// 3. Upsample: horizontal interpolation precomputed once per small row (and per mask row),
// then each output row lerps two precomputed rows — no per-pixel 2-D bilinear.
var blurRows = UpsampleColorRows(small, sw, sh, width);
var (b0, b1, bfy) = AxisLut(sh, height);
var maskRows = UpsampleMaskRows(mask, width);
var (m0, m1, mfy) = AxisLut(mask.Height, height);
// 4. Blend: person (mask) keeps the source pixel, background takes the blur.
var dst = new byte[src.Length];
for (var y = 0; y < height; y++)
Parallel.For(0, height, RowParallelism, y =>
{
var my = MaskCoord(y, height, mask.Height);
var by = (y + 0.5f) / f - 0.5f;
var ma = maskRows[m0[y]];
var mb = maskRows[m1[y]];
var mt = mfy[y];
var ba = blurRows[b0[y]];
var bb = blurRows[b1[y]];
var bt = bfy[y];
var row = y * stride;
for (var x = 0; x < width; x++)
{
var m = SampleBilinear(mask, MaskCoord(x, width, mask.Width), my);
var p = row + x * 4;
if (m >= 1f)
var m = ma[x] + (mb[x] - ma[x]) * mt;
if (m >= 0.999f)
{
dst[p] = src[p]; dst[p + 1] = src[p + 1]; dst[p + 2] = src[p + 2]; dst[p + 3] = src[p + 3];
continue;
}
SampleSmall(small, sw, sh, (x + 0.5f) / f - 0.5f, by, out var bb, out var bg, out var br);
if (m < 0f) m = 0f;
var k = 1f - m;
dst[p] = (byte)Math.Clamp(src[p] * m + bb * k + 0.5f, 0, 255);
dst[p + 1] = (byte)Math.Clamp(src[p + 1] * m + bg * k + 0.5f, 0, 255);
dst[p + 2] = (byte)Math.Clamp(src[p + 2] * m + br * k + 0.5f, 0, 255);
var c = x * 3;
var vb = ba[c] + (bb[c] - ba[c]) * bt;
var vg = ba[c + 1] + (bb[c + 1] - ba[c + 1]) * bt;
var vr = ba[c + 2] + (bb[c + 2] - ba[c + 2]) * bt;
dst[p] = ClampByte(src[p] * m + vb * k);
dst[p + 1] = ClampByte(src[p + 1] * m + vg * k);
dst[p + 2] = ClampByte(src[p + 2] * m + vr * k);
dst[p + 3] = src[p + 3];
}
}
});
return new VideoFrame(width, height, dst) { Epoch = frame.Epoch, IsOpaque = frame.IsOpaque };
}
/// <summary>Row-parallel pixel loops, capped so the effect worker can't starve the compositor
/// and encoder threads.</summary>
private static readonly ParallelOptions RowParallelism = new()
{
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount / 2, 1, 4),
};
private static byte ClampByte(float v)
{
var i = (int)(v + 0.5f);
return (byte)(i < 0 ? 0 : i > 255 ? 255 : i);
}
/// <summary>For each destination index along one axis: the two source indices and the
/// fractional weight — pixel-center aligned, edge-clamped (the same mapping a bilinear
/// sample uses).</summary>
private static (int[] I0, int[] I1, float[] F) AxisLut(int srcSize, int dstSize)
{
var i0 = new int[dstSize];
var i1 = new int[dstSize];
var fr = new float[dstSize];
for (var d = 0; d < dstSize; d++)
{
var s = (d + 0.5f) * srcSize / dstSize - 0.5f;
var a = (int)MathF.Floor(s);
var t = s - a;
i0[d] = Math.Clamp(a, 0, srcSize - 1);
i1[d] = Math.Clamp(a + 1, 0, srcSize - 1);
fr[d] = a < 0 ? 0f : a + 1 > srcSize - 1 ? 0f : t;
}
return (i0, i1, fr);
}
/// <summary>Each mask row, horizontally interpolated (and clamped to 0..1) to the frame width.</summary>
private static float[][] UpsampleMaskRows(SegmentationMask mask, int width)
{
var (x0, x1, fx) = AxisLut(mask.Width, width);
var rows = new float[mask.Height][];
var v = mask.Values;
for (var r = 0; r < mask.Height; r++)
{
var row = new float[width];
var o = r * mask.Width;
for (var x = 0; x < width; x++)
{
var a = v[o + x0[x]];
var m = a + (v[o + x1[x]] - a) * fx[x];
row[x] = m < 0f ? 0f : m > 1f ? 1f : m;
}
rows[r] = row;
}
return rows;
}
/// <summary>Each row of a 3-channel float image, horizontally interpolated to <paramref name="width"/>.</summary>
private static float[][] UpsampleColorRows(float[] img, int w, int h, int width)
{
var (x0, x1, fx) = AxisLut(w, width);
var rows = new float[h][];
Parallel.For(0, h, RowParallelism, r =>
{
var row = new float[width * 3];
var o = r * w;
for (var x = 0; x < width; x++)
{
var a = (o + x0[x]) * 3;
var b = (o + x1[x]) * 3;
var t = fx[x];
row[x * 3] = img[a] + (img[b] - img[a]) * t;
row[x * 3 + 1] = img[a + 1] + (img[b + 1] - img[a + 1]) * t;
row[x * 3 + 2] = img[a + 2] + (img[b + 2] - img[a + 2]) * t;
}
rows[r] = row;
});
return rows;
}
private static void BoxBlurHorizontal(float[] input, float[] output, int w, int h, int r)
{
var norm = 1f / (2 * r + 1);
@@ -230,47 +332,8 @@ public sealed class BackgroundEffectProcessor
}
}
private static void SampleSmall(float[] img, int w, int h, float x, float y, out float b, out float g, out float r)
{
var x0 = (int)MathF.Floor(x);
var y0 = (int)MathF.Floor(y);
var fx = x - x0;
var fy = y - y0;
var x1 = Math.Clamp(x0 + 1, 0, w - 1);
var y1 = Math.Clamp(y0 + 1, 0, h - 1);
x0 = Math.Clamp(x0, 0, w - 1);
y0 = Math.Clamp(y0, 0, h - 1);
var i00 = (y0 * w + x0) * 3;
var i01 = (y0 * w + x1) * 3;
var i10 = (y1 * w + x0) * 3;
var i11 = (y1 * w + x1) * 3;
var w00 = (1 - fx) * (1 - fy);
var w01 = fx * (1 - fy);
var w10 = (1 - fx) * fy;
var w11 = fx * fy;
b = img[i00] * w00 + img[i01] * w01 + img[i10] * w10 + img[i11] * w11;
g = img[i00 + 1] * w00 + img[i01 + 1] * w01 + img[i10 + 1] * w10 + img[i11 + 1] * w11;
r = img[i00 + 2] * w00 + img[i01 + 2] * w01 + img[i10 + 2] * w10 + img[i11 + 2] * w11;
}
private static float MaskCoord(int pixel, int frameSize, int maskSize)
=> (pixel + 0.5f) * maskSize / frameSize - 0.5f;
private static float SampleBilinear(SegmentationMask mask, float x, float y)
{
var x0 = (int)MathF.Floor(x);
var y0 = (int)MathF.Floor(y);
var fx = x - x0;
var fy = y - y0;
var x1 = Math.Clamp(x0 + 1, 0, mask.Width - 1);
var y1 = Math.Clamp(y0 + 1, 0, mask.Height - 1);
x0 = Math.Clamp(x0, 0, mask.Width - 1);
y0 = Math.Clamp(y0, 0, mask.Height - 1);
var v = mask.Values;
var top = v[y0 * mask.Width + x0] * (1 - fx) + v[y0 * mask.Width + x1] * fx;
var bottom = v[y1 * mask.Width + x0] * (1 - fx) + v[y1 * mask.Width + x1] * fx;
return Math.Clamp(top * (1 - fy) + bottom * fy, 0f, 1f);
}
private void LogFailureOnce(string reason)
{
@@ -0,0 +1,16 @@
using ytLive.Models;
namespace ytLive.Services.BackgroundEffects;
/// <summary>Identifies one rendered variant of a camera's frames (TASK 51): the effect plus its
/// strength, rounded to 2 decimals so a slider drag doesn't spawn a variant per pixel of travel.
/// <see cref="BackgroundEffect.None"/> always normalizes to strength 0 — the raw path.</summary>
public readonly record struct WebcamEffectKey(BackgroundEffect Effect, double Strength)
{
public static WebcamEffectKey None => new(BackgroundEffect.None, 0);
public static WebcamEffectKey For(BackgroundEffect effect, double strength)
=> effect == BackgroundEffect.None
? None
: new WebcamEffectKey(effect, Math.Round(Math.Clamp(strength, 0, 1), 2));
}
+199 -1
View File
@@ -6,6 +6,8 @@ using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services.BackgroundEffects;
namespace ytLive.Services;
@@ -21,9 +23,23 @@ namespace ytLive.Services;
/// <paramref name="firstFrameTimeout"/>), and an async <see cref="SourceFailed"/>
/// tears the session down and surfaces <see cref="CameraFailed"/> — no silent
/// empty box.
///
/// Background effects (TASK 51) are the ONE processing point on the camera path: per device,
/// the set of requested <see cref="WebcamEffectKey"/>s (<see cref="SetRequestedEffects"/>) is
/// rendered from each raw frame on a background worker — one segmentation per frame, shared by
/// every variant, latest-frame-drop when the worker is busy — and each variant keeps its own
/// latest frame (the compositor) and shared WriteableBitmap (the preview). The raw path is
/// untouched, so a slow or failing model never stalls the plain webcam.
/// </summary>
public sealed class CameraManager : IDisposable
{
private sealed class EffectVariant
{
public WriteableBitmap? PreviewBitmap;
public VideoFrame? LatestFrame;
public bool FramePending;
}
private sealed class CameraSession
{
public string DeviceId { get; }
@@ -36,6 +52,9 @@ public sealed class CameraManager : IDisposable
public VideoFrame? LatestFrame;
public bool FramePending;
public TaskCompletionSource<bool>? FirstFrame;
public readonly Dictionary<WebcamEffectKey, EffectVariant> Effects = new();
public bool EffectWorkerRunning;
public VideoFrame? LastEffectSource;
public CameraSession(string deviceId, ICameraFrameSource source)
{
@@ -50,11 +69,18 @@ public sealed class CameraManager : IDisposable
private readonly Dispatcher? _uiDispatcher;
private readonly TimeSpan _firstFrameTimeout;
private readonly Dictionary<string, CameraSession> _sessions = new();
private readonly Dictionary<string, HashSet<WebcamEffectKey>> _requestedEffects = new();
private readonly object _gate = new();
private readonly BackgroundEffectProcessor? _effects;
private readonly EffectTimer _effectTimer = new();
/// <summary>Raised on the UI thread when a camera's shared preview bitmap is first created.</summary>
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
/// <summary>Raised on the UI thread when an effect variant's preview bitmap is first created
/// (device id, effect key, bitmap) — the effect counterpart of <see cref="PreviewBitmapChanged"/>.</summary>
public event Action<string, WebcamEffectKey, WriteableBitmap>? EffectPreviewBitmapChanged;
/// <summary>
/// Raised when a capture fails: device in use, access denied, no preview source,
/// or a started reader that never delivers a first frame within the timeout.
@@ -62,12 +88,55 @@ public sealed class CameraManager : IDisposable
public event Action<string, string>? CameraFailed;
public CameraManager(ICameraEnumerator enumerator, Func<string, ICameraFrameSource> frameSourceFactory,
Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null)
Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null,
BackgroundEffectProcessor? effects = null)
{
_enumerator = enumerator;
_frameSourceFactory = frameSourceFactory;
_uiDispatcher = uiDispatcher;
_firstFrameTimeout = firstFrameTimeout ?? TimeSpan.FromSeconds(4);
_effects = effects;
}
/// <summary>
/// Declares which background-effect variants a device must render (the union over every
/// scene's webcam config). <see cref="BackgroundEffect.None"/> keys are ignored — that's the
/// raw path. Variants no longer requested are dropped (their worker cost stops); new ones
/// start rendering from the next camera frame. Survives the session starting later.
/// </summary>
public void SetRequestedEffects(string deviceId, IEnumerable<WebcamEffectKey> keys)
{
if (string.IsNullOrWhiteSpace(deviceId)) return;
lock (_gate)
{
var set = new HashSet<WebcamEffectKey>();
foreach (var key in keys)
if (key.Effect != BackgroundEffect.None) set.Add(key);
_requestedEffects[deviceId] = set;
if (_sessions.TryGetValue(deviceId, out var session))
SyncVariants(session, set);
}
}
/// <summary>The latest frame for a variant: the raw frame for <see cref="BackgroundEffect.None"/>,
/// otherwise the processed frame — null until the first one is rendered (an un-processed
/// frame is never shown in its place, so Remove never flashes the real room).</summary>
public VideoFrame? GetLatestFrame(string deviceId, WebcamEffectKey key)
{
if (key.Effect == BackgroundEffect.None) return GetLatestFrame(deviceId);
lock (_gate)
return _sessions.TryGetValue(deviceId, out var session)
&& session.Effects.TryGetValue(key, out var variant) ? variant.LatestFrame : null;
}
/// <summary>The shared preview bitmap for a variant (raw bitmap for None), or null until
/// its first frame — then <see cref="EffectPreviewBitmapChanged"/> fires.</summary>
public WriteableBitmap? GetPreviewBitmap(string deviceId, WebcamEffectKey key)
{
if (key.Effect == BackgroundEffect.None) return GetPreviewBitmap(deviceId);
lock (_gate)
return _sessions.TryGetValue(deviceId, out var session)
&& session.Effects.TryGetValue(key, out var variant) ? variant.PreviewBitmap : null;
}
public ICameraEnumerator Enumerator => _enumerator;
@@ -94,6 +163,8 @@ public sealed class CameraManager : IDisposable
session.Source.FrameAvailable += session.FrameHandler;
session.FailureHandler = message => OnSourceFailed(session, message);
session.Source.SourceFailed += session.FailureHandler;
if (_requestedEffects.TryGetValue(deviceId, out var requested))
SyncVariants(session, requested);
_sessions[deviceId] = session;
shouldStart = true;
}
@@ -277,6 +348,7 @@ public sealed class CameraManager : IDisposable
if (!TryGetActiveSession(session)) return;
session.LatestFrame = frame;
session.FirstFrame?.TrySetResult(true);
ScheduleEffects(session);
if (session.PreviewBitmap == null)
{
@@ -308,6 +380,132 @@ public sealed class CameraManager : IDisposable
}, DispatcherPriority.Render);
}
// Caller holds _gate.
private static void SyncVariants(CameraSession session, HashSet<WebcamEffectKey> keys)
{
foreach (var stale in session.Effects.Keys.Where(k => !keys.Contains(k)).ToList())
session.Effects.Remove(stale);
foreach (var key in keys)
if (!session.Effects.ContainsKey(key))
session.Effects[key] = new EffectVariant();
}
private void ScheduleEffects(CameraSession session)
{
if (_effects == null) return;
lock (_gate)
{
if (session.Effects.Count == 0 || session.EffectWorkerRunning) return;
session.EffectWorkerRunning = true;
}
_ = Task.Run(() => RunEffectWorker(session));
}
/// <summary>Renders every requested variant from the newest raw frame, then loops while a
/// newer frame has arrived; exits (and re-arms on the next frame) when caught up. One
/// segmentation per processed frame, whatever the number of variants.</summary>
private void RunEffectWorker(CameraSession session)
{
try
{
while (true)
{
VideoFrame? source;
List<WebcamEffectKey> keys;
lock (_gate)
{
source = session.LatestFrame;
if (!TryGetActiveSessionLocked(session) || session.Effects.Count == 0
|| source == null || ReferenceEquals(source, session.LastEffectSource))
{
session.EffectWorkerRunning = false;
return;
}
session.LastEffectSource = source;
keys = session.Effects.Keys.ToList();
}
var started = System.Diagnostics.Stopwatch.GetTimestamp();
var mask = _effects!.TrySegment(source);
var rendered = new List<(WebcamEffectKey Key, VideoFrame Frame)>(keys.Count);
if (mask != null)
foreach (var key in keys)
rendered.Add((key, BackgroundEffectProcessor.Render(source, mask, key.Effect, key.Strength)));
_effectTimer.Record(System.Diagnostics.Stopwatch.GetElapsedTime(started), source, keys.Count);
foreach (var (key, frame) in rendered)
{
EffectVariant? variant;
lock (_gate)
{
if (session.Effects.TryGetValue(key, out variant))
variant.LatestFrame = frame;
}
if (variant != null) PublishVariantPreview(session, key, variant);
}
}
}
catch (Exception ex)
{
lock (_gate) session.EffectWorkerRunning = false;
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}");
}
}
private void PublishVariantPreview(CameraSession session, WebcamEffectKey key, EffectVariant variant)
{
if (_uiDispatcher == null) return;
lock (_gate)
{
if (variant.FramePending) return;
variant.FramePending = true;
}
_uiDispatcher.BeginInvoke(() =>
{
VideoFrame? frame;
lock (_gate)
{
variant.FramePending = false;
if (!TryGetActiveSessionLocked(session) || !session.Effects.TryGetValue(key, out var current)
|| !ReferenceEquals(current, variant)) return;
frame = variant.LatestFrame;
}
if (frame == null) return;
var bitmap = variant.PreviewBitmap;
if (bitmap == null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height)
{
bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
variant.PreviewBitmap = bitmap;
EffectPreviewBitmapChanged?.Invoke(session.DeviceId, key, bitmap);
return;
}
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
}, DispatcherPriority.Render);
}
private bool TryGetActiveSessionLocked(CameraSession session)
=> _sessions.TryGetValue(session.DeviceId, out var current) && ReferenceEquals(current, session);
/// <summary>Perf record for the creator's-box check (TASK 51): logs the average
/// segment + render cost once per session after 120 processed frames.</summary>
private sealed class EffectTimer
{
private int _count;
private double _totalMs;
private bool _logged;
public void Record(TimeSpan elapsed, VideoFrame frame, int variants)
{
if (_logged) return;
_totalMs += elapsed.TotalMilliseconds;
if (++_count < 120) return;
_logged = true;
AppLog.Write($"CameraManager: background effects avg {_totalMs / _count:0.0} ms/frame " +
$"({frame.Width}x{frame.Height}, {variants} variant(s), {_count} frames)");
}
}
private void EnsurePreviewBitmap(CameraSession session)
{
if (session.PreviewBitmap != null || session.LatestFrame == null) return;
+2 -1
View File
@@ -18,6 +18,7 @@ External-facing logic: YouTube API, persistence. See
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `BackgroundEffects/IPersonSegmenter.cs` | **TASK 51 seam:** `Segment(VideoFrame) → SegmentationMask?` (`SegmentationMask(Width, Height, float[] Values)`, 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes |
| `BackgroundEffects/OnnxPersonSegmenter.cs` | **TASK 51 default segmenter:** MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) |
| `BackgroundEffects/WebcamEffectKey.cs` | **TASK 51:** `(Effect, Strength)` variant key, strength rounded to 0.01; `None` normalizes to strength 0 (the raw path) |
| `BackgroundEffects/BackgroundEffectProcessor.cs` | **TASK 51 pure pixel stage:** `Apply(frame, BackgroundEffect)` — None = same instance, Remove = copy with alpha = feathered mask × source alpha (`IsOpaque=false`), Blur = background blurred at ¼ res (3 box passes) and mask-blended, person untouched; strength 0..1; `TrySegment` once + `Render` per effect; no mask/exception → passthrough + one log |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
@@ -27,7 +28,7 @@ External-facing logic: YouTube API, persistence. See
| `IMicrophoneEnumerator.cs` | `GetMicrophonesAsync()` — seam so the mic picker never touches WinRT (tests inject fakes) |
| `WinRtMicrophoneEnumerator.cs` | WinRT mic enumeration via `DeviceInformation.FindAllAsync(DeviceClass.AudioCapture)` (no NAudio needed — capture libs stay deferred to the audio pipeline milestone) |
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`). **Validated, not trusted:** post-init checks (`VideoDeviceId` match, `FrameSources` non-empty, `VideoDeviceController.GetAvailableMediaStreamProperties` ≥ 1); `reader.StartAsync()` status read (throws on non-`Success`); `capture.Failed` + `CameraStreamStateChanged` subscribed → `SourceFailed` event. Fallback ladder: VideoPreview → VideoRecord retry. `SharingMode.Exclusive` + `DeviceLost` not in 19041 SDK projection; `CameraStreamState.Failed` compared by `(int)2` (projection omits member names) |
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. **First-frame proof** (4s timeout, configurable via ctor `TimeSpan?`): `AcquireAsync` returns true only after a real frame arrives — a reader that starts but never delivers (locked, suspended, dead) fails and surfaces `CameraFailed` with suspect-app names (`CameraConflictProbe`) instead of a silent empty box. `SourceFailed` forwarded from the frame source as async death signal |
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. **First-frame proof** (4s timeout, configurable via ctor `TimeSpan?`): `AcquireAsync` returns true only after a real frame arrives — a reader that starts but never delivers (locked, suspended, dead) fails and surfaces `CameraFailed` with suspect-app names (`CameraConflictProbe`) instead of a silent empty box. `SourceFailed` forwarded from the frame source as async death signal. **TASK 51:** the one background-effects processing point — `SetRequestedEffects(device, keys)` + per-variant `GetLatestFrame`/`GetPreviewBitmap(device, key)` + `EffectPreviewBitmapChanged`; a background worker segments once per raw frame and renders every variant (latest-frame-drop); raw path untouched |
| `IFullScreenDetector.cs` | Seam for the win32 full-screen detector: `int? GetForegroundFullScreenMonitorIndex()`, `int PrimaryMonitorIndex()`, `IReadOnlyList<DisplayInfo> GetDisplays()` |
| `Win32FullScreenDetector.cs` | `GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` + `GetMonitorInfo`; monitor covers all four edges → full-screen; own process excluded; monitor order = `EnumDisplayMonitors` order (static `GetMonitorHandle(int)` maps index→HMONITOR for the capture factory). `GetDisplays()` returns `DisplayInfo` (index/name/resolution/bounds/`IsPrimary`, friendly name via `EnumDisplayDevices`) for the in-app "Capture Display" picker; `PrimaryMonitorIndex()` is the auto-key fallback when no full-screen game is detected |
| `IScreenCaptureSource.cs` | `Key` + `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam so `ScreenCaptureManager` never touches WinRT (tests inject fakes) |
+5 -5
View File
@@ -85,7 +85,7 @@ delivery shape changed:
## 3. ☐ Test seam (Good Dog — one integration test per unit)
- ✅ Model-runner interface seam (`IPersonSegmenter`) so tests inject a fake segmenter.
- ☐ Assert the **processed frame reaches both consumers** (preview bitmap + compositor frame).
- ✅ The **processed frame reaches both consumers** (preview bitmap + compositor frame), raw stays raw (`CameraManagerTests`, slice 4).
- ✅ `None` = same-instance passthrough of the raw frame (`BackgroundEffectEngineTests`).
- ✅ `Remove` = alpha 0 outside the person, RGB untouched, non-opaque (`BackgroundEffectEngineTests`);
✅ `Blur` = background smoothed, person pixel-identical (`BackgroundEffectEngineTests`).
@@ -96,14 +96,14 @@ delivery shape changed:
- ☐ Engine failure → passthrough + single log, layer never dropped (code path exists; dedicated
test lands with the hookup slice).
- ☐ Real inference: **manual on the creator's box** (camera + lighting), not in CI.
- ☐ **Perf check in the hookup slice:** time `Segment` + `Apply` per 720p frame on the creator's
box before wiring it into the 60fps path (expect a few ms; the mask can run at a lower rate
than the camera if needed).
- ✅ **Perf, synthetic 720p (Debug):** segment 1.6 ms, Remove 3.3 ms, Blur 17.5 ms (after making the
upsampling separable — naive was 46/80 ms). Runs on its own worker, off the raw path.
☐ **Real numbers on the creator's box:** `startup.log` → "background effects avg … ms/frame".
## 4. Sequencing
- ✅ Separate work unit after the zoom build (zoom shipped 2026-10-08).
- Slice order: 1 engine ✅ → 2 Blur ✅ → 3 config + persistence ✅ → 4 camera-path hookup (one
- Slice order: 1 engine ✅ → 2 Blur ✅ → 3 config + persistence ✅ → 4 camera-path hookup ✅ (one
processing point, both consumers) + perf check → 5 properties-panel UI.
## Related (not duplicated here)
+1
View File
@@ -252,6 +252,7 @@ public partial class MainViewModel
if (element is WebcamSceneConfig)
{
SyncWebcamEffects(); // drop a variant nobody shows any more
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(WebcamAddToolTip));
+53 -6
View File
@@ -7,6 +7,7 @@ using ytLive;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.BackgroundEffects;
namespace ytLive.ViewModels;
@@ -193,9 +194,8 @@ public partial class MainViewModel
// A session already running (same device, or one that produced a first
// frame) has its shared bitmap; freshly loaded configs must adopt it here,
// because PreviewBitmapChanged never re-fires for an existing bitmap.
if (_cameraManager.GetPreviewBitmap(newDevice) is { } running)
foreach (var config in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
config.VideoImageSource = running;
// SyncWebcamEffects also declares each config's background-effect variant.
SyncWebcamEffects();
}
// CameraManager creates the shared WriteableBitmap on the UI thread at the
@@ -209,7 +209,52 @@ public partial class MainViewModel
RaiseWebcamGateChanged();
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
if (EffectKeyFor(config).Effect == BackgroundEffect.None)
config.VideoImageSource = bitmap;
}
// TASK 51: an effect variant's first processed frame created its bitmap — every config
// showing that variant adopts it (later frames write into it in place).
private void OnCameraEffectPreviewBitmapChanged(string deviceId, WebcamEffectKey key, WriteableBitmap bitmap)
{
if (_webcam?.DeviceId != deviceId) return;
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
if (EffectKeyFor(config) == key)
config.VideoImageSource = bitmap;
}
internal static WebcamEffectKey EffectKeyFor(WebcamSceneConfig config)
=> WebcamEffectKey.For(config.BackgroundEffect, config.BackgroundStrength);
private readonly HashSet<WebcamSceneConfig> _effectWatchedConfigs = new();
/// <summary>TASK 51: declares the union of every scene's webcam effect variant to the camera
/// (unused variants stop costing CPU) and points each config's preview at its variant's
/// bitmap. An effect variant with no processed frame yet shows nothing rather than the raw
/// room. Re-run whenever a config is added/removed/loaded or its effect changes.</summary>
internal void SyncWebcamEffects()
{
if (_webcam == null || string.IsNullOrWhiteSpace(_webcam.DeviceId)) return;
var configs = Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()).ToList();
_cameraManager.SetRequestedEffects(_webcam.DeviceId, configs.Select(EffectKeyFor));
foreach (var config in configs)
{
if (_effectWatchedConfigs.Add(config))
config.PropertyChanged += OnWebcamConfigEffectChanged;
var key = EffectKeyFor(config);
var bitmap = _cameraManager.GetPreviewBitmap(_webcam.DeviceId, key);
if (bitmap != null || key.Effect != BackgroundEffect.None)
config.VideoImageSource = bitmap;
}
}
private void OnWebcamConfigEffectChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is not (nameof(WebcamSceneConfig.BackgroundEffect) or nameof(WebcamSceneConfig.BackgroundStrength)))
return;
SyncWebcamEffects();
ScheduleSave();
}
// The camera failed to start or died asynchronously (in use, offline, locked,
@@ -321,9 +366,9 @@ public partial class MainViewModel
// PreviewBitmapChanged fires only on the camera's first frame, so a config
// added while the session is already running must pick up the shared bitmap
// directly (it's written in place from then on).
if (_cameraManager.GetPreviewBitmap(_webcam.DeviceId) is { } running)
config.VideoImageSource = running;
// directly (it's written in place from then on) — SyncWebcamEffects does that,
// per the config's background-effect variant.
SyncWebcamEffects();
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
if (!started)
@@ -346,6 +391,8 @@ public partial class MainViewModel
if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id)
await _cameraManager.ReleaseAllAsync(oldDevice);
SyncWebcamEffects(); // the new device inherits the scenes' background effects
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
{
var started = await _cameraManager.AcquireAsync(device.Id);
+9 -2
View File
@@ -276,8 +276,11 @@ public partial class MainViewModel : ViewModelBase
_cameraManager = new CameraManager(
_cameraEnumerator,
CameraFrameSourceFactoryOverride ?? (id => new MediaCaptureFrameSource(id)),
System.Windows.Application.Current?.Dispatcher);
System.Windows.Application.Current?.Dispatcher,
effects: new Services.BackgroundEffects.BackgroundEffectProcessor(
PersonSegmenterOverride ?? new Services.BackgroundEffects.OnnxPersonSegmenter()));
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
_cameraManager.EffectPreviewBitmapChanged += OnCameraEffectPreviewBitmapChanged;
_cameraManager.CameraFailed += OnCameraFailed;
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
@@ -384,6 +387,10 @@ public partial class MainViewModel : ViewModelBase
internal static ICameraEnumerator? CameraEnumeratorOverride { get; set; }
internal static Func<string, ICameraFrameSource>? CameraFrameSourceFactoryOverride { get; set; }
/// <summary>Test seam (TASK 51): a fake person segmenter so hermetic tests never load the
/// real ONNX model. Mirrors <see cref="CameraFrameSourceFactoryOverride"/>.</summary>
internal static Services.BackgroundEffects.IPersonSegmenter? PersonSegmenterOverride { get; set; }
private void LoadLayout()
{
_isLoading = true;
@@ -570,7 +577,7 @@ public partial class MainViewModel : ViewModelBase
// Looking up by the GUID returned null forever — the webcam was structurally
// invisible on the output while the preview (bitmap path) looked fine
// (caught by the resolver logging, 2026-09-01 take two).
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId)),
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId), EffectKeyFor(webcam)),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
Source { Type: SourceType.AlertBox } alertBox => RenderAlertBox(alertBox),
+18
View File
@@ -645,6 +645,24 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
(0..1, default 0.5), persisted as two additive guarded columns on `WebcamSceneConfig`
(`BackgroundEffect TEXT DEFAULT 'None'`, `BackgroundStrength REAL DEFAULT 0.5`, no version bump —
the TASK 47 convention). `RemoveBackground`/`BlurBackground` are the radio-pill bools the UI binds.
- **Hookup (slice 4) — the ONE processing point is `CameraManager`.** Effects are per scene but the
camera session is shared, so the manager renders **variants** keyed by `WebcamEffectKey(effect,
strength rounded to 0.01)`. `MainViewModel.SyncWebcamEffects()` declares the union of every
config's key (`SetRequestedEffects`; unused variants are dropped so their CPU stops) and points each
config's `VideoImageSource` at its variant's bitmap; it re-runs on load, add, remove, device swap,
and any `BackgroundEffect`/`BackgroundStrength` change (configs are watched via `PropertyChanged`,
which also `ScheduleSave`s). Per raw frame a background worker (latest-frame-drop, one at a time)
segments ONCE and renders every variant; each variant keeps its own latest frame (the resolver:
`GetLatestFrame(device, key)`) and WriteableBitmap (`EffectPreviewBitmapChanged` on first
creation). **The raw path is untouched** — a slow/failed model never stalls the plain webcam — and
an effect variant shows **nothing** until its first processed frame (Remove never flashes the
real room). The compositor needed no change: its sampler already honours source alpha.
- **Perf (synthetic 1280×720, Debug build):** segment 1.6 ms, Remove 3.3 ms, Blur 17.5 ms per frame
(Release is several × faster). Pixel loops are row-parallel, capped at min(4, cores/2); the
bilinear upsampling is separable (horizontal rows precomputed per mask/small row, one vertical lerp
per pixel) — the first naive per-pixel 2-D bilinear cost 46/80 ms. `CameraManager` logs the real
average once per session after 120 processed frames (`startup.log`: "background effects avg …").
`MainViewModel.PersonSegmenterOverride` is the hermetic-test seam.
- **Tests:** `BackgroundEffectEngineTests` — the Remove integration test (fake segmenter), and a
model contract test that runs the BUNDLED model through the C# preprocessing on a fixture and
matches the MediaPipe-produced reference mask (≥99.5% agreement; fixture = MediaPipe's public test
+95
View File
@@ -1,8 +1,13 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.BackgroundEffects;
namespace ytLive.Tests;
@@ -183,4 +188,94 @@ public class CameraManagerTests
Assert.Null(manager.GetLatestFrame("dev1"));
Assert.Contains("dev1", stopped);
}
private sealed class HalfMaskSegmenter : IPersonSegmenter
{
public int Calls;
public SegmentationMask? Segment(VideoFrame frame)
{
Interlocked.Increment(ref Calls);
return new SegmentationMask(2, 1, new[] { 1f, 0f }); // left person, right background
}
}
/// <summary>TASK 51 slice 4 integration test — the one processing point: with a Remove
/// variant requested, a raw camera frame reaches BOTH consumers processed — the
/// compositor's latest frame for that variant and the variant's preview bitmap on the UI
/// dispatcher — while the raw path keeps the untouched frame.</summary>
[Fact]
public async Task Requested_Remove_Variant_Reaches_Compositor_And_Preview_Raw_Stays_Raw()
{
Dispatcher? ui = null;
using var ready = new ManualResetEventSlim();
var uiThread = new Thread(() =>
{
ui = Dispatcher.CurrentDispatcher;
ready.Set();
Dispatcher.Run();
});
uiThread.SetApartmentState(ApartmentState.STA);
uiThread.IsBackground = true;
uiThread.Start();
ready.Wait();
try
{
FakeFrameSource? source = null;
var segmenter = new HalfMaskSegmenter();
var manager = new CameraManager(
new FakeEnumerator(),
id => source = new FakeFrameSource(id, new List<string>(), new List<string>()),
ui, TimeSpan.Zero, new BackgroundEffectProcessor(segmenter));
var key = WebcamEffectKey.For(BackgroundEffect.Remove, 0.5);
manager.SetRequestedEffects("dev1", new[] { key, WebcamEffectKey.None });
var previewArrived = new TaskCompletionSource<WriteableBitmap>(TaskCreationOptions.RunContinuationsAsynchronously);
manager.EffectPreviewBitmapChanged += (device, k, bitmap) =>
{
if (device == "dev1" && k == key) previewArrived.TrySetResult(bitmap);
};
Assert.True(await manager.AcquireAsync("dev1"));
const int w = 16, h = 4;
var pixels = new byte[w * h * 4];
for (var i = 0; i < pixels.Length; i += 4) { pixels[i] = 50; pixels[i + 1] = 60; pixels[i + 2] = 70; pixels[i + 3] = 255; }
var raw = new VideoFrame(w, h, pixels) { IsOpaque = true };
source!.Pump(raw);
var bitmap = await previewArrived.Task.WaitAsync(TimeSpan.FromSeconds(10));
// Raw consumer: the very same frame, untouched.
Assert.Same(raw, manager.GetLatestFrame("dev1"));
Assert.Same(raw, manager.GetLatestFrame("dev1", WebcamEffectKey.None));
// Compositor consumer: the processed variant.
var processed = manager.GetLatestFrame("dev1", key);
Assert.NotNull(processed);
Assert.False(processed!.IsOpaque);
Assert.Equal(255, processed.BgraPixels[(1 * w + 1) * 4 + 3]); // person side
Assert.Equal(0, processed.BgraPixels[(1 * w + (w - 2)) * 4 + 3]); // background side
// Preview consumer: the variant's bitmap carries the same cut-out.
var previewPixels = new byte[w * h * 4];
var previewWidth = await ui!.InvokeAsync(() =>
{
bitmap.CopyPixels(previewPixels, w * 4, 0);
return bitmap.PixelWidth;
});
Assert.Equal(w, previewWidth);
Assert.Equal(255, previewPixels[(1 * w + 1) * 4 + 3]);
Assert.Equal(0, previewPixels[(1 * w + (w - 2)) * 4 + 3]);
Assert.Same(bitmap, manager.GetPreviewBitmap("dev1", key));
Assert.True(segmenter.Calls >= 1);
// Dropping the request stops the variant.
manager.SetRequestedEffects("dev1", new[] { WebcamEffectKey.None });
Assert.Null(manager.GetLatestFrame("dev1", key));
}
finally
{
ui?.InvokeShutdown();
}
}
}