feat: web sources frame-captured via composition (CoreWebView2CompositionController → Windows.Graphics.Capture); PNG poll + CaptureScheduler deleted

The ~30Hz CapturePreviewAsync PNG poll capped real cadence at ~20Hz
(35–165ms full-HD encode+decode), so a 60fps widget still juddered at ~1/6
speed. Replaced polling with frame-driven capture of the composition
controller's root visual — the mechanism WebView2CompositionControl and
Flutter's webview_windows use (graphics_context.cc captures the root
surface_ visual via CreateGraphicsCaptureItemFromVisual; reference:
github.com/microsoft/Windows.UI.Composition.WinUI / flutter-internal
webview_windows). Frames now arrive at the renderer's own pace; capture
memory is epoch'd ring reuse + one crop-sized shared WriteableBitmap.

New Services/WebCaptureFrameSource.cs owns GraphicsCaptureItem + free-
threaded Direct3D11CaptureFramePool + session (Straight alpha readback,
per-frame FindContentBounds → CropBounds). WebView2Manager reworked around
per-session composition controllers + one UI-thread Compositor created via
the CoreMessaging CreateDispatcherQueueController P/Invoke (the 19041
projection lacks CreateOnCurrentThread); internal seam ctor
(Dispatcher, Func<string,IScreenCaptureSource>?) for hermetic tests.
CaptureScheduler.cs deleted; the three SetCaptureInterval cadence hooks
removed; InitWebView2() moved from MainWindow ctor to Loaded (a parent
HWND must exist for the composition controller); the hidden WebViewHostPanel
overlay deleted. TransparentBackgroundScript unchanged.

Tests: WebView2ManagerTests reworked — 4 control-size + scheduler tests
dropped, FindContentBounds tests moved to WebCaptureFrameSource, ONE
integration test (Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds)
drives the seam with a FakeWebSource + background-STA DispatcherPump.
Suite 293/293, 0 warnings.

NOTE: composition path NOT yet verified on a device — the take is the
next step. Web work committed locally only (no push per standing rule).

Docs same-commit: ai.md Slice 14 + supersede marker on Slice 11, HANDOFF,
MyMistakes (CoreMessaging DQ + namespace-landmine recipe), TASK 17,
Controls/ViewModels/Services indexes.
This commit is contained in:
2026-09-14 16:14:00 -07:00
parent 40c453805d
commit c01206fb8a
16 changed files with 890 additions and 581 deletions
-73
View File
@@ -1,73 +0,0 @@
using System;
using System.Threading.Tasks;
using System.Windows.Threading;
namespace ytLive.Services;
/// <summary>Per-session web capture pace: a DispatcherTimer that drops ticks while a
/// capture is in flight (latest-wins). Raising the naive 10Hz cadence to ~30Hz would
/// stack concurrent CapturePreviewAsync calls (a full 1920×1080 PNG encode costs
/// ~10-30ms), so overlapping ticks are dropped, never queued; the effective cadence
/// is max(interval, capture duration). The interval and the in-flight drop live here,
/// not in the manager, so the scheduler is unit-testable without a WebView2 runtime.</summary>
internal sealed class CaptureScheduler
{
private readonly Dispatcher _dispatcher;
private readonly Func<Task> _capture;
private DispatcherTimer? _timer;
private int _intervalMs;
private bool _inFlight;
private Task _pending = Task.CompletedTask;
public CaptureScheduler(Dispatcher dispatcher, int intervalMs, Func<Task> capture)
{
_dispatcher = dispatcher;
_intervalMs = Math.Max(16, intervalMs);
_capture = capture;
}
/// <summary>The task of the capture currently in flight, or a completed task when
/// idle. Test seam: lets the caller await exactly the running capture.</summary>
internal Task Pending => _pending;
public void Start()
{
if (_timer != null) return;
_timer = new DispatcherTimer(
TimeSpan.FromMilliseconds(_intervalMs),
DispatcherPriority.Background,
(_, _) => Tick(),
_dispatcher);
_timer.Start();
}
public void Stop() => _timer?.Stop();
public void SetInterval(int milliseconds)
{
_intervalMs = Math.Max(16, milliseconds);
if (_timer != null) _timer.Interval = TimeSpan.FromMilliseconds(_intervalMs);
}
/// <summary>Runs the capture unless one is already in flight — the dropped tick is
/// the latest-wins policy. Tick runs on the dispatcher thread (timer + test), so
/// _inFlight needs no locking.</summary>
internal void Tick()
{
if (_inFlight) return;
_inFlight = true;
_pending = RunCapture();
}
private async Task RunCapture()
{
try
{
await _capture();
}
finally
{
_inFlight = false;
}
}
}
+232
View File
@@ -0,0 +1,232 @@
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading.Tasks;
using Windows.Graphics;
using Windows.Graphics.Capture;
using Windows.Graphics.DirectX;
using Windows.Graphics.DirectX.Direct3D11;
using Windows.Graphics.Imaging;
using ytLive.Helpers;
namespace ytLive.Services;
/// <summary>
/// The frame-driven web capture for one widget: a GraphicsCaptureItem backed by
/// the widget's Windows.UI.Composition visual (see WebView2Manager). Owns the
/// item, a free-threaded Direct3D11CaptureFramePool and the capture session;
/// frames arrive in step with Chromium's own presentation (~60Hz on a real
/// animation), replacing the old CapturePreviewAsync PNG poll whose ~35-165ms
/// encode cost capped the recording at ~10Hz and the preview at ~5fps.
///
/// The capture surface holds a REAL alpha channel (the controller renders onto
/// a transparent composition visual), so the GPU→CPU copy uses
/// <see cref="BitmapAlphaMode.Straight"/> and the frame is
/// <see cref="VideoFrame.IsOpaque"/> = false — the compositor's straight-alpha
/// blend math ("web layer transparency", 2026-09-13) requires the readback to
/// keep premultiplication OFF. Unlike the screen source there is no master-size
/// downscale: the controller renders at the RAW 1920×1080 master canvas, so the
/// CPU never holds a larger frame.
///
/// Per-frame the alpha bounding box of the widget (<see cref="FindContentBounds"/>)
/// is measured on this capture worker thread and stamped onto the frame as
/// <see cref="VideoFrame.CropBounds"/> — same alpha-bbox contract as the old
/// PNG path, but off the UI thread (a 2M-pixel scan per frame there is exactly
/// the cost this redesign exists to remove).
/// </summary>
public sealed class WebCaptureFrameSource : IScreenCaptureSource
{
private readonly object _gate = new();
private GraphicsCaptureItem _item;
private Direct3D11CaptureFramePool? _framePool;
private GraphicsCaptureSession? _session;
private SizeInt32 _poolSize;
private bool _started;
private bool _framePending;
private DateTime _lastErrorLog = DateTime.MinValue;
// Buffer recycling: identical to the screen source's ring+Epoch contract
// (see ScreenCaptureFrameSource) — the compositor's paste cache is keyed on
// array identity, so recycled buffers must carry a monotonic Epoch or a
// reused array false-hits with stale content.
private readonly byte[]?[] _frameRing = new byte[8][];
private int _ringNext;
private long _epoch;
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
public string Key { get; }
public event Action<VideoFrame>? FrameAvailable;
internal WebCaptureFrameSource(string key, GraphicsCaptureItem item)
{
Key = key;
_item = item;
}
/// <summary>Wraps an item created from a composition visual by the web manager.</summary>
public static WebCaptureFrameSource CreateForVisual(string key, GraphicsCaptureItem item)
=> new(key, item);
public Task StartAsync()
{
lock (_gate)
{
if (_started) return Task.CompletedTask;
var item = _item;
var device = Direct3D11Helper.CreateDevice();
var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size);
_poolSize = item.Size;
var session = framePool.CreateCaptureSession(item);
framePool.FrameArrived += OnFrameArrived;
session.StartCapture();
_framePool = framePool;
_session = session;
_started = true;
}
return Task.CompletedTask;
}
public Task StopAsync()
{
lock (_gate)
{
_started = false;
_framePending = false;
if (_framePool != null)
_framePool.FrameArrived -= OnFrameArrived;
_session?.Dispose();
_session = null;
_framePool?.Dispose();
_framePool = null;
}
return Task.CompletedTask;
}
private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args)
{
var frame = sender.TryGetNextFrame();
if (frame == null) return;
lock (_gate)
{
if (!_started)
{
frame.Dispose();
return;
}
}
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
{
sender.Recreate(Direct3D11Helper.CreateDevice(),
DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, frame.ContentSize);
_poolSize = frame.ContentSize;
}
if (_framePending)
{
frame.Dispose();
return;
}
_framePending = true;
_ = ProcessFrameAsync(frame);
}
private async Task ProcessFrameAsync(Direct3D11CaptureFrame frame)
{
try
{
using (frame)
using (var softwareBitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(
frame.Surface, BitmapAlphaMode.Straight))
{
FrameAvailable?.Invoke(CopyToVideoFrame(softwareBitmap));
}
}
catch (Exception ex)
{
var now = DateTime.UtcNow;
if (now - _lastErrorLog >= ErrorLogThrottle)
{
_lastErrorLog = now;
AppLog.Write($"WebCaptureFrameSource: frame conversion failed: {ex.Message}");
}
}
finally
{
_framePending = false;
}
}
private byte[] RentRingBuffer(int size)
{
for (var tries = 0; tries < _frameRing.Length; tries++)
{
var idx = (_ringNext + tries) % _frameRing.Length;
var buf = _frameRing[idx];
if (buf is { Length: var len } && len == size)
{
_ringNext = (idx + 1) % _frameRing.Length;
return buf;
}
}
var slot = _ringNext;
_ringNext = (slot + 1) % _frameRing.Length;
var fresh = new byte[size];
_frameRing[slot] = fresh;
return fresh;
}
private VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap)
{
var sw = bitmap.PixelWidth;
var sh = bitmap.PixelHeight;
using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
using var reference = buffer.CreateReference();
if (!WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var data, out var capacity))
throw new InvalidOperationException("Could not access the frame buffer.");
var srcStride = sw * 4;
var count = (int)Math.Min(capacity, (uint)(sh * srcStride));
var pixels = RentRingBuffer(count);
Marshal.Copy(data, pixels, 0, pixels.Length);
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, sw, sh, srcStride);
return new VideoFrame(sw, sh, pixels)
{
IsOpaque = false,
Epoch = ++_epoch,
CropBounds = (cropX, cropY, cropW, cropH),
};
}
// The widget's extent is measured from the captured frame itself: the
// bounding box of non-transparent pixels (the background is injected
// transparent, so the alpha channel IS the widget). Immune to DOM timing
// (fonts/images/animations), needs no JS round-trip, and CANNOT break the
// render: a widget that fills the canvas gets the full frame; a widget that
// floats in transparent margin gets exactly its content box — used for the
// preview bitmap / selection. The frame handed to the compositor is always
// the FULL canvas, so the page's transparent margins keep revealing the
// layers beneath (the "black square over webcam" was the crop fed to the
// compositor: UniformToFill zoomed the opaque content box over the element).
internal static (int X, int Y, int W, int H) FindContentBounds(byte[] pixels, int pixW, int pixH, int stride)
{
int minX = pixW, minY = pixH, maxX = -1, maxY = -1;
for (var y = 0; y < pixH; y++)
{
var row = y * stride;
for (var x = 0; x < pixW; x++)
{
if (pixels[row + x * 4 + 3] == 0) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (maxX < minX || maxY < minY) return (0, 0, pixW, pixH);
return (minX, minY, maxX - minX + 1, maxY - minY + 1);
}
}
+308 -310
View File
@@ -1,129 +1,92 @@
using System;
using System.Diagnostics;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Text.Json;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using Windows.Graphics.Capture;
using Windows.System;
using Windows.UI.Composition;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Owns the composition-based web sources (one per widget). A session renders
/// its widget through a <see cref="CoreWebView2CompositionController"/> whose
/// visual tree lives off-screen on the WPF UI thread (OBS web-source model:
/// the page renders at the MASTER CANVAS size 1920×1080, stable, never tracked);
/// the controller's root visual is captured by a <see cref="WebCaptureFrameSource"/>
/// at Chromium's own presentation cadence. The preview is crop-copied to a shared
/// WriteableBitmap on the UI thread (dispatcher-coalesced latest-wins, mirror of
/// ScreenCaptureManager); the FramePump reads <see cref="GetLatestFrame"/>.
///
/// The composition stack needs a DispatcherQueue on this thread (CsWinRT does not
/// project <c>CreateOnCurrentThread</c> on the 19041 SDK — only the dedicated
/// variant — so it is installed via a CoreMessaging P/Invoke, the documented
/// fallback the WebView2CompositionControl / webview_windows embedders use).
/// Everything composition-side therefore runs on the WPF UI thread; only the
/// frame conversion and alpha-bbox scan run on the capture worker.
///
/// Seam: an internal <c>Func&lt;string, IScreenCaptureSource&gt;</c> source
/// factory substitutes the whole WebView2+item creation in tests — the manager
/// then owns the session/coalesce/crop/preview plumbing exactly as in production.
/// </summary>
public sealed class WebView2Manager : IDisposable
{
private readonly Panel _hostPanel;
private readonly Dispatcher _dispatcher;
private readonly Func<string, IScreenCaptureSource>? _sessionSourceFactory;
private readonly Dictionary<string, WebSourceSession> _sessions = new();
private Task<CoreWebView2Environment>? _environmentTask;
private int _captureIntervalMs = 200; // idle pace; recording drops it to ~30Hz via SetCaptureInterval
private DispatcherQueueController? _dispatcherQueueController;
private Windows.UI.Composition.Compositor? _compositor;
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
private sealed class WebSourceSession
{
public required WebView2 Control { get; init; }
public required CaptureScheduler Scheduler { get; init; }
public required string Key { get; init; }
public CoreWebView2? Web;
public CoreWebView2CompositionController? Controller;
public IScreenCaptureSource? Source;
public Action<VideoFrame>? FrameHandler;
public VideoFrame? LatestFrame;
public WriteableBitmap? PreviewBitmap;
// Buffer reuse (2026-09-04 lesson): the preview crop copy reuses one
// scratch array while the widget's extent holds — no per-frame LOH mint.
public byte[]? CropScratch;
public bool Disposed;
public bool Initialized;
// Buffer reuse (2026-09-04): every capture tick used to mint a fresh
// 8.3MB canvas array + a fresh crop array (~83MB/s of LOH at 10Hz →
// gen2 pauses surfaced as the pump's 35-40ms "worst render" spikes).
// CanvasScratch is reused while the size holds. The frame handed to the
// compositor is always the FULL canvas (transparent margins reveal layers
// beneath); the crop is only for the preview WriteableBitmap. Epoch is
// monotonic per canvas generation (identity-keyed consumers see each frame).
public byte[]? CanvasScratch;
public readonly byte[]?[] OutRing = new byte[8][];
public int OutRingNext;
public long Epoch;
public WriteableBitmap? PreviewBitmap;
public bool DebugPngWritten;
public int WidgetDumpRemaining;
public int TelemetryCaptureCount;
public byte[] RentOutBuffer(int size)
{
for (var tries = 0; tries < OutRing.Length; tries++)
{
var idx = (OutRingNext + tries) % OutRing.Length;
var buf = OutRing[idx];
if (buf != null && buf.Length == size)
{
OutRingNext = (idx + 1) % OutRing.Length;
return buf;
}
}
var slot = OutRingNext;
OutRingNext = (slot + 1) % OutRing.Length;
var fresh = new byte[size];
OutRing[slot] = fresh;
return fresh;
}
public bool FramePending;
public bool FirstCaptureLogged;
public int WidgetLogsRemaining;
}
public WebView2Manager(Panel hostPanel, Dispatcher dispatcher)
public WebView2Manager(Dispatcher dispatcher)
: this(dispatcher, null)
{
}
/// <summary>Test seam: the source factory replaces WebView2+composition
/// creation, so the session/coalesce/crop/preview plumbing is testable
/// without the runtime.</summary>
internal WebView2Manager(Dispatcher dispatcher, Func<string, IScreenCaptureSource>? sessionSourceFactory)
{
_hostPanel = hostPanel;
_dispatcher = dispatcher;
_sessionSourceFactory = sessionSourceFactory;
}
public void Register(Source source)
{
if (_sessions.ContainsKey(source.Id)) return;
// OBS model: the page renders at the MASTER CANVAS size (1920×1080),
// stable, never tracked. Full-bleed widgets (designed for the canvas)
// render fully instead of being clipped to a small box-sized viewport;
// dragging the box never reflows the page. The capture is tightly
// cropped to the widget's ALPHA bounding box (FindContentBounds) and
// the display (Stretch=Fill) maps it flush under the box, anchored at
// (0,0) — no DOM query, so no timing fragility; worst case is the
// full frame, which is the confirmed-good rendering.
var webView = new WebView2
{
Width = 1920,
Height = 1080,
DefaultBackgroundColor = System.Drawing.Color.Transparent,
Visibility = System.Windows.Visibility.Visible,
};
Canvas.SetLeft(webView, -5000);
Canvas.SetTop(webView, -5000);
_hostPanel.Children.Add(webView);
var scheduler = new CaptureScheduler(
_dispatcher,
_captureIntervalMs,
() => CaptureFrame(source.Id));
var session = new WebSourceSession
{
Control = webView,
Scheduler = scheduler,
};
_sessions[source.Id] = session;
_ = InitializeAsync(source, session);
}
/// <summary>Changes the capture cadence for every session. Recording runs at
/// ~30Hz (33ms) — the practical PNG-capture ceiling — idle drops back to 200ms so
/// the preview alone doesn't burn a core decoding full-HD captures.</summary>
public void SetCaptureInterval(int milliseconds)
{
_captureIntervalMs = milliseconds;
foreach (var session in _sessions.Values)
if (!session.Disposed)
session.Scheduler.SetInterval(milliseconds);
_sessions[source.Id] = new WebSourceSession { Key = source.Id };
_ = InitializeAsync(source, _sessions[source.Id]);
}
public void Unregister(string sourceId) => RemoveSession(sourceId);
@@ -136,7 +99,7 @@ public sealed class WebView2Manager : IDisposable
if (string.IsNullOrEmpty(source.WebUri))
{
session.LatestFrame = null;
session.Control.CoreWebView2?.NavigateToString(
session.Web?.NavigateToString(
"<html><body style='background:transparent;margin:0'></body></html>");
return;
}
@@ -152,9 +115,8 @@ public sealed class WebView2Manager : IDisposable
// Injected BEFORE the document is parsed (AddScriptToExecuteOnDocumentCreatedAsync) so
// the widget page's own CSS/scripts cannot repaint html/body opaque — the reason the
// late NavigationStarting/NavigationCompleted ExecuteScriptAsync injection lost the
// fight (page styles ran first). CapturePreviewAsync "will always honor a webpage's
// background content" (MicrosoftEdge/WebView2Feedback specs/BackgroundColor.md), so a
// transparent capture REQUIRES the transparency to be in place before the page paints.
// fight (page styles ran first). A transparent capture REQUIRES the transparency in
// place before the page paints, on the composition surface too.
//
// The OLD inline `element.style.background='transparent'` form lost to any page CSS:
// a widget that paints a background-COLOR on a container (or html/body with stronger
@@ -191,55 +153,75 @@ public sealed class WebView2Manager : IDisposable
{
try
{
await session.Control.EnsureCoreWebView2Async(await GetEnvironmentAsync());
var cws = session.Control.CoreWebView2!;
// Pre-page hook: runs after the global object exists but BEFORE the document
// parses / any page script runs — must complete before navigating away from
// about:blank for it to apply to the widget document.
await cws.AddScriptToExecuteOnDocumentCreatedAsync(TransparentBackgroundScript);
cws.NavigationStarting += (_, _) =>
IScreenCaptureSource sourceImpl;
if (_sessionSourceFactory != null)
{
_ = cws.ExecuteScriptAsync(TransparentBackgroundScript);
};
cws.NavigationCompleted += (_, e) =>
// Test path: the factory hands over a fully-configured source; the
// branch has no awaits, so Register() completes the session inline.
sourceImpl = _sessionSourceFactory(source.Id);
}
else
{
_ = cws.ExecuteScriptAsync(TransparentBackgroundScript);
if (e.IsSuccess && session.Control.CoreWebView2?.Source is { } src && src != "about:blank")
var controller = await CreateControllerAsync();
if (controller == null)
{
// Arm a one-shot diagnostic dump of the REAL widget document
// (a few post-paint captures so styles/backgrounds settle) —
// the old first-capture dump only ever saw the about:blank
// document, so whether this widget's capture actually HAS the
// transparent margins the injection promises has never been
// verified (MyMistakes: "? VERIFY").
session.WidgetDumpRemaining = 5;
// The first five dumps fire within ~1s of completion (usually BEFORE the
// recording starts) — re-arm ~30s in so the same real document gets dumped
// INSIDE a recording's window too. Next take therefore nukes the gap that
// hid what the page actually painted while recording.
var rearm = session;
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(30));
rearm.WidgetDumpRemaining = 5;
});
RemoveSession(source.Id);
return;
}
_ = CaptureFrame(source.Id);
};
session.Controller = controller;
session.Web = controller.CoreWebView2;
if (session.Web == null)
{
RemoveSession(source.Id);
return;
}
// Pre-page hook: runs after the global object exists but BEFORE the document
// parses / any page script runs — must complete before navigating away from
// about:blank for it to apply to the widget document.
await session.Web.AddScriptToExecuteOnDocumentCreatedAsync(TransparentBackgroundScript);
session.Web.NavigationStarting += (_, _) =>
{
_ = session.Web?.ExecuteScriptAsync(TransparentBackgroundScript);
};
session.Web.NavigationCompleted += (_, e) =>
{
_ = session.Web?.ExecuteScriptAsync(TransparentBackgroundScript);
if (e.IsSuccess && session.Web?.Source is { } src && src != "about:blank")
{
// Log-only diagnostic on the REAL widget document (a few post-paint
// frames so styles/backgrounds settle). The first-capture dump only
// ever saw about:blank; the old PNG widget dumps are gone with the
// PNG path — alpha stats now ride the frame-driven channel.
session.WidgetLogsRemaining = 3;
var rearm = session;
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(30));
if (!rearm.Disposed) rearm.WidgetLogsRemaining = 3;
});
}
};
session.Web.NavigateToString(
"<html><body style='background:transparent;margin:0'></body></html>");
if (!string.IsNullOrEmpty(source.WebUri))
Navigate(source.WebUri, session);
session.Initialized = true;
sourceImpl = new WebCaptureFrameSource(source.Id, CreateCaptureItem(controller));
}
session.Source = sourceImpl;
session.FrameHandler = frame => OnFrameAvailable(session, frame);
sourceImpl.FrameAvailable += session.FrameHandler;
await sourceImpl.StartAsync();
session.Initialized = true;
session.Control.CoreWebView2.NavigateToString(
"<html><body style='background:transparent;margin:0'></body></html>");
if (!string.IsNullOrEmpty(source.WebUri))
Navigate(source.WebUri, session);
session.Scheduler.Start();
}
catch (Exception ex)
{
@@ -248,14 +230,13 @@ public sealed class WebView2Manager : IDisposable
}
}
// The widget page is rendered off-screen; Chromium throttles hidden pages
// (rAF parked, timers clamped to ~1s) the moment the app window is unfocused or
// covered — WebView2Feedback#1172/#3070, Chrome 88 timer throttling. These flags
// (the Electron-class embedder answer) keep the renderer at full rate:
// --disable-backgrounding-occluded-windows, --disable-renderer-backgrounding,
// and disable the native-window occlusion detector that marks the page hidden.
// The environment MUST exist before EnsureCoreWebView2Async — it's created once
// and shared by every session (one browser process).
// The environment MUST exist before the controller is created — it's created once
// and shared by every session (one browser process). These flags (the
// Electron-class embedder answer to Chromium throttling hidden pages — the moment
// the app window is unfocused or covered, rAF parks and timers clamp to ~1s) keep
// the renderer at full rate: --disable-backgrounding-occluded-windows,
// --disable-renderer-backgrounding, and disable the native-window occlusion
// detector that marks the page hidden.
private Task<CoreWebView2Environment> GetEnvironmentAsync()
{
if (_environmentTask != null) return _environmentTask;
@@ -267,45 +248,177 @@ public sealed class WebView2Manager : IDisposable
return _environmentTask = CoreWebView2Environment.CreateAsync(null, null, options);
}
/// <summary>Creates the composition controller off-screen under the main
/// window (the parent HWND is required, the visual never mounts a window
/// target). Set up is copied from the WebView2CompositionControl /
/// webview_windows embedders: raw-pixel bounds at the master canvas,
/// fixed rasterization scale, transparent default background.</summary>
private async Task<CoreWebView2CompositionController?> CreateControllerAsync()
{
var mainWindow = Application.Current?.MainWindow;
var hwnd = mainWindow == null
? IntPtr.Zero
: new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle;
if (hwnd == IntPtr.Zero)
{
AppLog.Write("WebView2Manager: main window handle unavailable — web source not created");
return null;
}
var env = await GetEnvironmentAsync();
if (_compositor == null) _compositor = CreateCompositorForCurrentThread();
var controller = await env.CreateCoreWebView2CompositionControllerAsync(hwnd);
controller.Bounds = new Rectangle(0, 0, 1920, 1080);
controller.BoundsMode = CoreWebView2BoundsMode.UseRawPixels;
controller.ShouldDetectMonitorScaleChanges = false;
controller.RasterizationScale = 1.0;
controller.IsVisible = true;
controller.DefaultBackgroundColor = System.Drawing.Color.Transparent;
return controller;
}
private void Navigate(string url, WebSourceSession session)
{
try { session.Control.CoreWebView2?.Navigate(url); }
try { session.Web?.Navigate(url); }
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
}
// The widget's extent is measured from the captured frame itself: the
// bounding box of non-transparent pixels (the background is injected
// transparent, so the alpha channel IS the widget). This is immune to
// DOM timing (fonts/images/animations), needs no JS round-trip, and
// CANNOT break the render: if the widget fills the canvas the box equals
// the full frame; if it floats in transparent margin the box is the
// measured content extent — used ONLY for the preview bitmap / selection.
// The frame handed to the compositor is always the FULL canvas, so the
// page's transparent margins keep revealing the layers beneath (the
// "black square over webcam" was the crop fed to the compositor:
// UniformToFill zoomed the opaque content box to cover the whole element).
internal static (int X, int Y, int W, int H) FindContentBounds(byte[] pixels, int pixW, int pixH, int stride)
// The visual tree webview_windows wires (offscreen mode): a root container
// visual sized to the master canvas (the captured subject — the Flutter
// plugin calls GraphicsCaptureItem.CreateFromVisual on this root), with the
// controller's root visual target set to a child that fills the root via
// RelativeSizeAdjustment. BoundsMode raw pixels + RasterizationScale 1.0 keep
// the capture at exactly 1920×1080 regardless of monitor scale.
private GraphicsCaptureItem CreateCaptureItem(CoreWebView2CompositionController controller)
{
int minX = pixW, minY = pixH, maxX = -1, maxY = -1;
for (var y = 0; y < pixH; y++)
{
var row = y * stride;
for (var x = 0; x < pixW; x++)
{
if (pixels[row + x * 4 + 3] == 0) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (maxX < minX || maxY < minY) return (0, 0, pixW, pixH);
return (minX, minY, maxX - minX + 1, maxY - minY + 1);
var compositor = _compositor!;
var root = compositor.CreateContainerVisual();
root.Size = new System.Numerics.Vector2(1920f, 1080f);
root.IsVisible = true;
var webViewVisual = compositor.CreateContainerVisual();
webViewVisual.RelativeSizeAdjustment = new System.Numerics.Vector2(1f, 1f);
root.Children.InsertAtTop(webViewVisual);
controller.RootVisualTarget = webViewVisual;
return GraphicsCaptureItem.CreateFromVisual(root);
}
// CsWinRT does not project DispatcherQueueController.CreateOnCurrentThread
// on the 19041 SDK (only CreateOnDedicatedThread [CS0117, verified by probe]);
// the documented fallback — "how to create a DispatcherQueue on the current
// thread" (learn.microsoft.com CoreMessaging docs) — is CreateDispatcherQueueController
// from CoreMessaging.dll, wrapped via FromAbi (the CaptureInterop pattern). One
// per UI thread, then new Compositor() (which requires the DQ) works here.
private Windows.UI.Composition.Compositor CreateCompositorForCurrentThread()
{
var options = new DispatcherQueueOptions
{
DwSize = Marshal.SizeOf<DispatcherQueueOptions>(),
ThreadType = 2, // DQTYPE_THREAD_CURRENT
ApartmentType = 2, // DQTAT_COM_STA — the WPF UI thread is STA
};
var hr = CreateDispatcherQueueController(options, out var controllerPtr);
if (hr != 0)
throw Marshal.GetExceptionForHR(hr)!;
_dispatcherQueueController = DispatcherQueueController.FromAbi(controllerPtr);
return new Windows.UI.Composition.Compositor();
}
[StructLayout(LayoutKind.Sequential)]
private struct DispatcherQueueOptions
{
public int DwSize;
public int ThreadType;
public int ApartmentType;
}
[DllImport("CoreMessaging.dll")]
private static extern int CreateDispatcherQueueController(
DispatcherQueueOptions options, out IntPtr dispatcherQueueController);
private void OnFrameAvailable(WebSourceSession session, VideoFrame frame)
{
if (session.Disposed) return;
session.LatestFrame = frame;
if (_dispatcher.CheckAccess())
{
UpdatePreview(session);
return;
}
if (session.FramePending) return;
session.FramePending = true;
_dispatcher.BeginInvoke(() =>
{
session.FramePending = false;
if (!session.Disposed)
UpdatePreview(session);
}, DispatcherPriority.Render);
}
private void UpdatePreview(WebSourceSession session)
{
var frame = session.LatestFrame;
if (frame == null) return;
var (cropX, cropY, cropW, cropH) = frame.CropBounds ?? (0, 0, frame.Width, frame.Height);
if (cropW < 1 || cropH < 1)
{
cropX = 0; cropY = 0;
cropW = frame.Width; cropH = frame.Height;
}
// The preview is sized to the widget's alpha bounding box (the compositor
// gets the FULL canvas + CropBounds; the crop is only for the preview).
// A fresh box (widget resizes / first content paints) mints a new bitmap
// once — WPF re-renders a reused mutable bitmap after WritePixels, so
// later frames only re-copy into the same one.
if (session.PreviewBitmap == null
|| session.PreviewBitmap.PixelWidth != cropW
|| session.PreviewBitmap.PixelHeight != cropH)
{
session.PreviewBitmap = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
PreviewBitmapChanged?.Invoke(session.Key, session.PreviewBitmap);
}
var need = cropW * cropH * 4;
var scratch = session.CropScratch is { } s && s.Length >= need
? s
: (session.CropScratch = new byte[need]);
var stride = frame.Stride;
if (cropW == frame.Width && cropH == frame.Height && cropX == 0 && cropY == 0)
Buffer.BlockCopy(frame.BgraPixels, 0, scratch, 0, need);
else
for (var r = 0; r < cropH; r++)
Buffer.BlockCopy(frame.BgraPixels, (cropY + r) * stride + cropX * 4, scratch, r * cropW * 4, cropW * 4);
session.PreviewBitmap.WritePixels(new Int32Rect(0, 0, cropW, cropH), scratch, cropW * 4, 0);
// alpha[min/max/mean/zero%] — the one number that decides whether a widget's
// capture really has transparent margins (0% = opaque, ~100% if the page
// painted transparent-only). Sampled every 16th pixel like the original log.
if (!session.FirstCaptureLogged)
{
session.FirstCaptureLogged = true;
AppLog.Write(
$"WebView2Manager: first capture for '{session.Key}' {frame.Width}x{frame.Height} " +
$"{AlphaStats(frame.BgraPixels, frame.Width, frame.Height, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
}
if (session.WidgetLogsRemaining > 0)
{
var dumpNo = 3 - session.WidgetLogsRemaining + 1;
session.WidgetLogsRemaining--;
AppLog.Write(
$"WebView2Manager: widget capture [{dumpNo}/3] for '{session.Key}' {frame.Width}x{frame.Height} " +
$"{AlphaStats(frame.BgraPixels, frame.Width, frame.Height, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
}
}
// alpha[min/max/mean/zero%] — the one number that decides whether a widget's
// capture really has transparent margins (0% = opaque, ~100% if the page
// painted transparent-only). Sampled every 16th pixel like the original log.
private static string AlphaStats(byte[] pixels, int pixW, int pixH, int stride)
{
int minA = 255, maxA = 0;
@@ -326,147 +439,32 @@ public sealed class WebView2Manager : IDisposable
return $"alpha[min={minA},max={maxA},mean={sumA / (double)nA:F1},zero={100.0 * zeroA / nA:F1}%]";
}
private async Task CaptureFrame(string sourceId)
{
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
var sw = Stopwatch.StartNew();
try
{
var webView = session.Control;
if (webView.CoreWebView2 == null) return;
using var ms = new MemoryStream();
await webView.CoreWebView2.CapturePreviewAsync(
CoreWebView2CapturePreviewImageFormat.Png, ms);
if (!session.DebugPngWritten)
{
session.DebugPngWritten = true;
try
{
ms.Position = 0;
var dbg = Path.Combine(Path.GetTempPath(), $"ytLive-web-{sourceId}.png");
File.WriteAllBytes(dbg, ms.ToArray());
AppLog.Write($"WebView2Manager: debug capture dump -> {dbg}");
}
catch (Exception dex) { AppLog.Write($"WebView2Manager: debug dump failed: {dex.Message}"); }
}
ms.Position = 0;
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.StreamSource = ms;
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
var formatted = new FormatConvertedBitmap(bitmap,
PixelFormats.Bgra32, null, 0);
var pixW = formatted.PixelWidth;
var pixH = formatted.PixelHeight;
if (pixW < 1 || pixH < 1) return;
var need = pixW * pixH * 4;
var pixels = session.CanvasScratch is { } scratch && scratch.Length >= need
? scratch
: (session.CanvasScratch = new byte[need]);
formatted.CopyPixels(pixels, pixW * 4, 0);
var stride = pixW * 4;
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride);
if (session.Epoch == 0)
{
AppLog.Write(
$"WebView2Manager: first capture for '{sourceId}' {pixW}x{pixH} " +
$"{AlphaStats(pixels, pixW, pixH, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
}
if (session.WidgetDumpRemaining > 0)
{
var dumpNo = 5 - session.WidgetDumpRemaining + 1;
session.WidgetDumpRemaining--;
try
{
var dbg = Path.Combine(Path.GetTempPath(), $"ytLive-web-{sourceId}-w{dumpNo}.png");
File.WriteAllBytes(dbg, ms.ToArray());
AppLog.Write(
$"WebView2Manager: widget capture [{dumpNo}/5] for '{sourceId}' {pixW}x{pixH} " +
$"{AlphaStats(pixels, pixW, pixH, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH}) -> {dbg}");
}
catch (Exception dex)
{
AppLog.Write($"WebView2Manager: widget dump failed: {dex.Message}");
}
}
var outPixels = session.RentOutBuffer(cropW * cropH * 4);
if (cropW == pixW && cropH == pixH && cropX == 0 && cropY == 0)
Buffer.BlockCopy(pixels, 0, outPixels, 0, outPixels.Length);
else
for (var r = 0; r < cropH; r++)
Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * cropW * 4, cropW * 4);
if (session.PreviewBitmap == null
|| session.PreviewBitmap.PixelWidth != cropW || session.PreviewBitmap.PixelHeight != cropH)
session.PreviewBitmap = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
session.PreviewBitmap.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0);
session.LatestFrame = new VideoFrame(pixW, pixH, pixels)
{
Epoch = ++session.Epoch,
CropBounds = (cropX, cropY, cropW, cropH),
};
// The preview bitmap is now MUTABLE and reused (CameraManager precedent):
// WPF re-renders it after WritePixels; handlers must not Freeze it.
var preview = session.PreviewBitmap;
_ = _dispatcher.BeginInvoke(() =>
{
PreviewBitmapChanged?.Invoke(sourceId, preview);
});
// Capture-cost telemetry for the first ~30 captures of a session: PNG encode
// (CapturePreviewAsync) + decode cost decide whether ~30Hz recording cadence
// is affordable or must drop to ~20Hz. The FramePump drops frames (never
// time-lapses) if the UI thread's GC churn starves it, so startup.log shows
// the real budget before anything is tuned further.
if (session.TelemetryCaptureCount < 30)
{
session.TelemetryCaptureCount++;
AppLog.Write(
$"WebView2Manager: capture #{session.TelemetryCaptureCount} for '{sourceId}' " +
$"{pixW}x{pixH} took {sw.ElapsedMilliseconds}ms");
}
}
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { }
catch (Exception ex)
{
AppLog.Write($"WebView2Manager: capture failed for '{sourceId}': {ex.Message}");
}
}
private void RemoveSession(string sourceId)
{
if (!_sessions.TryGetValue(sourceId, out var session)) return;
session.Disposed = true;
session.Scheduler.Stop();
_hostPanel.Children.Remove(session.Control);
try { session.Control.Dispose(); } catch { }
var src = session.Source;
if (src != null)
{
if (session.FrameHandler != null)
src.FrameAvailable -= session.FrameHandler;
_ = SafeStopAsync(src);
}
try { session.Controller?.Close(); } catch { }
session.Web = null;
_sessions.Remove(sourceId);
}
private static async Task SafeStopAsync(IScreenCaptureSource source)
{
try { await source.StopAsync(); }
catch (Exception ex) { AppLog.Write($"WebView2Manager: stopping web capture failed: {ex.Message}"); }
}
public void Dispose()
{
foreach (var id in _sessions.Keys.ToList())
RemoveSession(id);
}
}
}
+2
View File
@@ -28,6 +28,8 @@ External-facing logic: YouTube API, persistence. See
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<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) |
| `WebView2Manager.cs` | **Web-source ownership (TASK 17, composition-capture since 2026-09-14):** per registered `Source { Type: WebSource }` it owns a `CoreWebView2CompositionController` + its visual tree (`RootVisualTarget` child under a 1920×1080 root `ContainerVisual` on one `Windows.UI.Composition.Compositor` created on the UI thread via the CoreMessaging `CreateDispatcherQueueController` P/Invoke), a wrapped `WebCaptureFrameSource`, and the shared crop-sized `WriteableBitmap` preview (dispatcher-coalesced copy at Render priority, `PreviewBitmapChanged` raised only on recreation). Nav + `TransparentBackgroundScript` injection unchanged. Public ctor `(Dispatcher)`; internal seam ctor `(Dispatcher, Func<string, IScreenCaptureSource>?)` for hermetic tests (no WinRT). Caller: `MainViewModel.InitWebView2()` (now invoked from `MainWindow_Loaded` — the parent HWND must exist). See `../ai.md` "Slice 14" |
| `WebCaptureFrameSource.cs` | The `IScreenCaptureSource` for a web session: owns the `GraphicsCaptureItem` (`CreateFromVisual`) + a free-threaded `Direct3D11CaptureFramePool` (2 buffers) + session; readback via `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, **`BitmapAlphaMode.Straight`** — the compositor blends straight alpha); per-frame `FindContentBounds` alpha-bbox (internal static, unit-tested) stamps `VideoFrame.CropBounds`; ring up to 2 fresh frames + Epoch reuse; `IsOpaque=false`. Mirror of `ScreenCaptureFrameSource` |
| `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 → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). 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) |