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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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<string, IScreenCaptureSource></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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user