a5b9952cbd
- scrollWidth/scrollHeight returns the whole 1920x1080 canvas for widgets whose body/background spans it, so the crop kept massive transparent margins and the content floated in the Fill box. - ContentBoundsScript now unions every visible element's getBoundingClientRect, excluding elements that span >=98% of the viewport (bodies, full-canvas background layers); full-bleed widgets fall back to the full rect. Crop taken AT the rect's top-left (l,t) — content pins to bitmap (0,0). - Web Image explicitly Margin=0 + Left/Top alignment so the frame and widget coincide at the origin. - Canvas-size render untouched.
322 lines
12 KiB
C#
322 lines
12 KiB
C#
using System;
|
||
using System.Drawing;
|
||
using System.IO;
|
||
using System.Text.Json;
|
||
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 ytLive.Helpers;
|
||
using ytLive.Models;
|
||
|
||
namespace ytLive.Services;
|
||
|
||
public sealed class WebView2Manager : IDisposable
|
||
{
|
||
private readonly Panel _hostPanel;
|
||
private readonly Dispatcher _dispatcher;
|
||
private readonly Dictionary<string, WebSourceSession> _sessions = new();
|
||
|
||
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||
|
||
private sealed class WebSourceSession
|
||
{
|
||
public required WebView2 Control { get; init; }
|
||
public required DispatcherTimer CaptureTimer { get; init; }
|
||
public VideoFrame? LatestFrame;
|
||
public double ViewportCssW;
|
||
public double ViewportCssH;
|
||
public double ContentCssX;
|
||
public double ContentCssY;
|
||
public double ContentCssW;
|
||
public double ContentCssH;
|
||
public bool Disposed;
|
||
public bool Initialized;
|
||
}
|
||
|
||
public WebView2Manager(Panel hostPanel, Dispatcher dispatcher)
|
||
{
|
||
_hostPanel = hostPanel;
|
||
_dispatcher = dispatcher;
|
||
}
|
||
|
||
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 cropped to
|
||
// the widget's actual content bounds (QueryContentBoundsAsync) and the
|
||
// display (Stretch=Fill) maps it flush under the box, anchored at (0,0).
|
||
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 timer = new DispatcherTimer
|
||
{
|
||
Interval = TimeSpan.FromMilliseconds(100),
|
||
};
|
||
|
||
var session = new WebSourceSession
|
||
{
|
||
Control = webView,
|
||
CaptureTimer = timer,
|
||
};
|
||
|
||
_sessions[source.Id] = session;
|
||
|
||
timer.Tick += (_, _) =>
|
||
{
|
||
if (!session.Disposed && session.Initialized)
|
||
_ = CaptureFrame(source.Id);
|
||
};
|
||
|
||
_ = InitializeAsync(source, session);
|
||
}
|
||
|
||
public void Unregister(string sourceId) => RemoveSession(sourceId);
|
||
|
||
public void OnWebUriChanged(Source source)
|
||
{
|
||
if (!_sessions.TryGetValue(source.Id, out var session)) return;
|
||
if (session.Disposed || !session.Initialized) return;
|
||
|
||
if (string.IsNullOrEmpty(source.WebUri))
|
||
{
|
||
session.LatestFrame = null;
|
||
session.Control.CoreWebView2?.NavigateToString(
|
||
"<html><body style='background:transparent;margin:0'></body></html>");
|
||
return;
|
||
}
|
||
|
||
Navigate(source.WebUri, session);
|
||
}
|
||
|
||
public VideoFrame? GetLatestFrame(string sourceId)
|
||
{
|
||
return _sessions.TryGetValue(sourceId, out var session) ? session.LatestFrame : null;
|
||
}
|
||
|
||
private async Task InitializeAsync(Source source, WebSourceSession session)
|
||
{
|
||
try
|
||
{
|
||
await session.Control.EnsureCoreWebView2Async();
|
||
|
||
var cws = session.Control.CoreWebView2!;
|
||
|
||
cws.NavigationStarting += (_, _) =>
|
||
{
|
||
_ = cws.ExecuteScriptAsync(
|
||
"document.documentElement.style.background='transparent';" +
|
||
"document.documentElement.style.overflow='hidden';" +
|
||
"document.body.style.background='transparent';" +
|
||
"document.body.style.overflow='hidden';");
|
||
};
|
||
|
||
cws.NavigationCompleted += (_, _) =>
|
||
{
|
||
_ = cws.ExecuteScriptAsync(
|
||
"document.documentElement.style.background='transparent';" +
|
||
"document.documentElement.style.overflow='hidden';" +
|
||
"document.body.style.background='transparent';" +
|
||
"document.body.style.overflow='hidden';");
|
||
_ = QueryContentBoundsAsync(source.Id);
|
||
_ = CaptureFrame(source.Id);
|
||
};
|
||
|
||
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.CaptureTimer.Start();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
AppLog.Write($"WebView2Manager: init failed for '{source.Name}': {ex.Message}");
|
||
RemoveSession(source.Id);
|
||
}
|
||
}
|
||
|
||
private void Navigate(string url, WebSourceSession session)
|
||
{
|
||
try { session.Control.CoreWebView2?.Navigate(url); }
|
||
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
|
||
}
|
||
|
||
// The true widget rect for the crop. scrollWidth/scrollHeight is useless for
|
||
// widgets whose body/background spans the full 1920×1080 canvas — it returns
|
||
// the whole viewport, so the crop keeps massive transparent margins and the
|
||
// rendered bitmap floats inside the Fill-stretched box ("ghost boundary").
|
||
// Fix: union the getBoundingClientRect of every visible element, EXCLUDING
|
||
// elements that span (nearly) the full viewport — bodies and full-canvas
|
||
// background/overlay layers are layout wrappers, not widget content. If the
|
||
// exclusion empties the set, the widget IS full-bleed: use the full rect.
|
||
// Returned as a real JS object (NOT JSON.stringify — ExecuteScriptAsync
|
||
// JSON-encodes the result, so stringifying would double-encode and the
|
||
// parse would throw, silently disabling the crop).
|
||
private const string ContentBoundsScript =
|
||
"(function(){" +
|
||
"var vw=window.innerWidth||1,vh=window.innerHeight||1;" +
|
||
"var all=[];var body=document.body;" +
|
||
"if(body){var br=body.getBoundingClientRect();if(br.width||br.height)all.push(br);" +
|
||
"var els=body.querySelectorAll('*');" +
|
||
"for(var i=0;i<els.length;i++){" +
|
||
"var st=getComputedStyle(els[i]);" +
|
||
"if(st&&st.display==='none')continue;" +
|
||
"var r=els[i].getBoundingClientRect();" +
|
||
"if(r.width||r.height)all.push(r);" +
|
||
"}}" +
|
||
"if(!all.length)return null;" +
|
||
"var content=[];" +
|
||
"for(var j=0;j<all.length;j++){" +
|
||
"var rr=all[j];" +
|
||
"if(rr.width>=vw*0.98&&rr.height>=vh*0.98)continue;" +
|
||
"content.push(rr);" +
|
||
"}" +
|
||
"var use=content.length?content:all;" +
|
||
"var l=use[0].left,t=use[0].top,rt=use[0].right,bt=use[0].bottom;" +
|
||
"for(var k=1;k<use.length;k++){" +
|
||
"l=Math.min(l,use[k].left);" +
|
||
"t=Math.min(t,use[k].top);" +
|
||
"rt=Math.max(rt,use[k].right);" +
|
||
"bt=Math.max(bt,use[k].bottom);" +
|
||
"}" +
|
||
"l=Math.max(0,l);t=Math.max(0,t);" +
|
||
"return {l:l,t:t,w:Math.max(1,Math.min(vw-l,rt-l))," +
|
||
"h:Math.max(1,Math.min(vh-t,bt-t)),vw:vw,vh:vh};" +
|
||
"})()";
|
||
|
||
private async Task QueryContentBoundsAsync(string sourceId)
|
||
{
|
||
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
||
try
|
||
{
|
||
if (session.Control.CoreWebView2 is not { } cws) return;
|
||
var json = await cws.ExecuteScriptAsync(ContentBoundsScript);
|
||
if (string.IsNullOrEmpty(json) || json == "null") return;
|
||
using var doc = JsonDocument.Parse(json);
|
||
var root = doc.RootElement;
|
||
session.ViewportCssW = root.GetProperty("vw").GetDouble();
|
||
session.ViewportCssH = root.GetProperty("vh").GetDouble();
|
||
session.ContentCssX = root.GetProperty("l").GetDouble();
|
||
session.ContentCssY = root.GetProperty("t").GetDouble();
|
||
session.ContentCssW = root.GetProperty("w").GetDouble();
|
||
session.ContentCssH = root.GetProperty("h").GetDouble();
|
||
_ = CaptureFrame(sourceId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
AppLog.Write($"WebView2Manager: content bounds query failed: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private async Task CaptureFrame(string sourceId)
|
||
{
|
||
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
||
|
||
try
|
||
{
|
||
var webView = session.Control;
|
||
if (webView.CoreWebView2 == null) return;
|
||
|
||
using var ms = new MemoryStream();
|
||
await webView.CoreWebView2.CapturePreviewAsync(
|
||
CoreWebView2CapturePreviewImageFormat.Png, ms);
|
||
|
||
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 pixels = new byte[pixW * pixH * 4];
|
||
formatted.CopyPixels(pixels, pixW * 4, 0);
|
||
|
||
var stride = pixW * 4;
|
||
|
||
int cropX = 0, cropY = 0, cropW = pixW, cropH = pixH;
|
||
if (session.ViewportCssW >= 1 && session.ViewportCssH >= 1 &&
|
||
session.ContentCssW >= 1 && session.ContentCssH >= 1)
|
||
{
|
||
var sx = pixW / session.ViewportCssW;
|
||
var sy = pixH / session.ViewportCssH;
|
||
cropX = (int)Math.Clamp(Math.Round(session.ContentCssX * sx), 0, pixW - 1);
|
||
cropY = (int)Math.Clamp(Math.Round(session.ContentCssY * sy), 0, pixH - 1);
|
||
cropW = (int)Math.Clamp(Math.Round(session.ContentCssW * sx), 1, pixW - cropX);
|
||
cropH = (int)Math.Clamp(Math.Round(session.ContentCssH * sy), 1, pixH - cropY);
|
||
}
|
||
|
||
var outPixels = pixels;
|
||
if (cropW != pixW || cropH != pixH || cropX != 0 || cropY != 0)
|
||
{
|
||
var dstStride = cropW * 4;
|
||
outPixels = new byte[dstStride * cropH];
|
||
for (var r = 0; r < cropH; r++)
|
||
Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * dstStride, dstStride);
|
||
}
|
||
|
||
var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
|
||
wb.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0);
|
||
wb.Freeze();
|
||
|
||
session.LatestFrame = new VideoFrame(cropW, cropH, outPixels);
|
||
|
||
_ = _dispatcher.BeginInvoke(() =>
|
||
{
|
||
PreviewBitmapChanged?.Invoke(sourceId, wb);
|
||
});
|
||
}
|
||
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.CaptureTimer.Stop();
|
||
_hostPanel.Children.Remove(session.Control);
|
||
try { session.Control.Dispose(); } catch { }
|
||
_sessions.Remove(sourceId);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
foreach (var id in _sessions.Keys.ToList())
|
||
RemoveSession(id);
|
||
}
|
||
}
|