using System; using System.Linq; using System.Windows; using ytLive.Models; using ytLive.Services; using ytLive.Services.Compositor; namespace ytLive.ViewModels; /// /// The zoom box as a real composited layer (TASK 51): right-clicking the live /// preview places a 2x magnifier one z-slot above the background and below every /// other layer. The box drives a transient whose /// pixels are the magnified backdrop inside the box; the frame pump composites it /// into the stream/recording like any other layer (output stays the normal tier /// crop — the box is NEVER the crop source). All shapes are centered on the click. /// public partial class MainViewModel { public void ActivateZoom(double clickX, double clickY) { if (!(IsLive || IsRecording)) return; var maxW = MasterFrameWidth * 0.7; var maxH = MasterFrameHeight * 0.7; double w, h; switch (_zoomShape) { case ZoomShape.Circle: var size = Math.Min(maxW, maxH); w = size; h = size; break; case ZoomShape.VerticalRectangle: h = maxH; w = h * 9.0 / 16.0; break; default: w = maxW; h = w * 9.0 / 16.0; break; } // Option B anchor: the click point is the CENTER of every shape. Clamped // so the whole box stays inside the master 1920x1080 frame. var x = Math.Clamp(clickX - w / 2, 0, MasterFrameWidth - w); var y = Math.Clamp(clickY - h / 2, 0, MasterFrameHeight - h); _zoomState.X = x; _zoomState.Y = y; _zoomState.Width = w; _zoomState.Height = h; _zoomState.IsActive = true; _zoomState.IsMoving = false; var zoomTarget = EnsureZoomLayer(StagedScene); zoomTarget.X = x; zoomTarget.Y = y; zoomTarget.Width = w; zoomTarget.Height = h; zoomTarget.ClipShape = _zoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional; zoomTarget.IsZoomActive = true; zoomTarget.IsVisible = true; ScheduleSave(); RefreshZoomUi(); } public void ToggleZoom() { if (_zoomState.IsActive) DismissZoom(); } public void DismissZoom() { if (!_zoomState.IsActive) return; _zoomState.IsActive = false; _zoomState.IsMoving = false; var zoomTarget = StagedScene?.Elements.OfType().FirstOrDefault(); if (zoomTarget != null) { zoomTarget.IsZoomActive = false; zoomTarget.IsVisible = false; } if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene); RefreshZoomUi(); } public void StartZoomMove(double x, double y) { if (!_zoomState.IsActive) return; _zoomState.IsMoving = true; _zoomState.GrabOffsetX = x - _zoomState.X; _zoomState.GrabOffsetY = y - _zoomState.Y; OnPropertyChanged(nameof(ZoomState)); } public void UpdateZoomMove(double x, double y) { if (!_zoomState.IsActive || !_zoomState.IsMoving) return; var nx = x - _zoomState.GrabOffsetX; var ny = y - _zoomState.GrabOffsetY; _zoomState.X = Math.Clamp(nx, 0, MasterFrameWidth - _zoomState.Width); _zoomState.Y = Math.Clamp(ny, 0, MasterFrameHeight - _zoomState.Height); var zoomTarget = StagedScene?.Elements.OfType().FirstOrDefault(e => e.IsZoomActive); if (zoomTarget != null) { zoomTarget.X = _zoomState.X; zoomTarget.Y = _zoomState.Y; } OnPropertyChanged(nameof(ZoomState)); RebuildDimRectsForZoom(); } public void EndZoomMove() { if (_zoomState.IsMoving) { _zoomState.IsMoving = false; OnPropertyChanged(nameof(ZoomState)); } } private void RefreshZoomUi() { OnPropertyChanged(nameof(ZoomState)); OnPropertyChanged(nameof(IsZoomActive)); OnPropertyChanged(nameof(ShowZoomOverlay)); RebuildDimRectsForZoom(); } private void RebuildDimRectsForZoom() { if (_zoomState.IsActive) { var zx = _zoomState.X; var zy = _zoomState.Y; var zw = _zoomState.Width; var zh = _zoomState.Height; DimRects.Clear(); if (zx > 0) DimRects.Add(new Rect(0, 0, zx, MasterFrameHeight)); var rx = zx + zw; if (rx < MasterFrameWidth) DimRects.Add(new Rect(rx, 0, MasterFrameWidth - rx, MasterFrameHeight)); if (zy > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, zy)); var ry = zy + zh; if (ry < MasterFrameHeight) DimRects.Add(new Rect(0, ry, MasterFrameWidth, MasterFrameHeight - ry)); return; } RebuildQualityDimRects(); } /// Heals a missing zoom placeholder on every scene after a layout /// load: the invisible 0x0 row parked at z-index 1 (above the background, /// below every other layer), mirroring how backgrounds heal. The zoom box /// itself is transient — the placeholder survives so the layer has a seat. private void HealZoomLayers() { foreach (var scene in Scenes) EnsureZoomLayer(scene); } /// Gets or inserts the scene's zoom placeholder at z-index 1. private ZoomSceneElement EnsureZoomLayer(Scene? scene) { var existing = scene?.Elements.OfType().FirstOrDefault(); if (existing != null) return existing; var zoom = new ZoomSceneElement(); _sceneGraph.InsertElement(scene!, Math.Min(1, scene!.Elements.Count), zoom); return zoom; } // Single-slot zoom frame cache: the frame-pump resolver runs every tick, so // rebuilding the ~4 MB box raster per tick would churn the LOH even when the // backdrop and box did not change. One active zoom → one cached frame; the // key is the backdrop frames' identity+Epoch and the box rect's ints. private VideoFrame? _zoomFrame; private (byte[]?, long?, byte[]?, long?, int, int, int, int) _zoomFrameKey; private VideoFrame? BuildZoomFrame(ZoomSceneElement zoom) { var scene = StagedScene; if (scene == null) return null; // Mirror the compositor's backdrop ordering: live capture first, static // background art over it (SceneCompositor.Render, same conditions — live // only when visible, art whenever present). var live = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); var liveFrame = live is { IsVisible: true } ? ResolveOutputFrameCore(live) : null; var art = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); var artFrame = art != null ? ResolveOutputFrameCore(art) : null; if (liveFrame == null && artFrame == null) return null; var zx = (int)Math.Round(zoom.X); var zy = (int)Math.Round(zoom.Y); var zw = (int)Math.Ceiling(zoom.Width); var zh = (int)Math.Ceiling(zoom.Height); var key = (liveFrame?.BgraPixels, liveFrame?.Epoch, artFrame?.BgraPixels, artFrame?.Epoch, zx, zy, zw, zh); if (key != _zoomFrameKey) { _zoomFrame = ZoomFrameBuilder.Build( liveFrame, artFrame, zx, zy, zw, zh, MasterFrameWidth, MasterFrameHeight); _zoomFrameKey = key; } return _zoomFrame; } } /// Pure pixel builder for the zoom layer: samples the box/2 interior /// centred on the box centre from the sequenced backdrop (live capture then art) /// and upscales it 2x — the same region and magnification the preview's /// VisualBrush shows. Static, so the magnification proof is a plain unit test. internal static class ZoomFrameBuilder { internal static VideoFrame? Build( VideoFrame? liveFrame, VideoFrame? artFrame, int zx, int zy, int zw, int zh, double masterW, double masterH) { if (zw <= 0 || zh <= 0) return null; if (liveFrame == null && artFrame == null) return null; var buffer = new byte[zw * zh * 4]; for (var oy = 0; oy < zh; oy++) { // Source window = the box/2 interior CENTERED on the box center (the // same region as the preview's VisualBrush Viewbox), sampled at 2x. var my = zy + zh / 4.0 + (oy + 0.5) * 0.5; var di = oy * zw * 4; for (var ox = 0; ox < zw; ox++, di += 4) { var mx = zx + zw / 4.0 + (ox + 0.5) * 0.5; var b = 0; var g = 0; var r = 0; var a = 0; if (liveFrame != null) Sample(liveFrame, mx, my, masterW, masterH, ref b, ref g, ref r, ref a); if (artFrame != null) { var ab = 0; var ag = 0; var ar = 0; var aa = 0; Sample(artFrame, mx, my, masterW, masterH, ref ab, ref ag, ref ar, ref aa); if (aa >= 255) { b = ab; g = ag; r = ar; } else if (aa > 0) { var inv = 255 - aa; b = (b * inv + ab * aa + 127) / 255; g = (g * inv + ag * aa + 127) / 255; r = (r * inv + ar * aa + 127) / 255; } a = 255; } else if (liveFrame != null) { a = 255; } buffer[di] = (byte)b; buffer[di + 1] = (byte)g; buffer[di + 2] = (byte)r; buffer[di + 3] = (byte)a; } } return new VideoFrame(zw, zh, buffer); } /// Inverse of the compositor's full-frame backdrop blit: maps a master /// pixel back through (or the frame's /// CropBounds) into the source canvas and bilinear-samples it. Fixed-point /// bilinear, same precision as SceneCompositor.Bilinear. private static void Sample( VideoFrame src, double mx, double my, double masterW, double masterH, ref int b, ref int g, ref int r, ref int a) { var cbX = 0; var cbY = 0; var cbW = src.Width; var cbH = src.Height; float scale, ox, oy, drawnW, drawnH; if (src.CropBounds is { } cb && cb.W > 0 && cb.H > 0) { cbX = cb.X; cbY = cb.Y; cbW = cb.W; cbH = cb.H; scale = Math.Max((float)masterW / cbW, (float)masterH / cbH); drawnW = cbW * scale; drawnH = cbH * scale; ox = (float)((masterW - drawnW) / 2); oy = (float)((masterH - drawnH) / 2); } else { var (uScale, uOx, uOy) = StretchMath.UniformToFill((float)masterW, (float)masterH, src.Width, src.Height); scale = uScale; drawnW = src.Width * uScale; drawnH = src.Height * uScale; ox = uOx; oy = uOy; } var sxFloat = cbX + (mx - ox) / drawnW * cbW; var syFloat = cbY + (my - oy) / drawnH * cbH; if (sxFloat < 0f) sxFloat = 0f; if (sxFloat > src.Width - 1f) sxFloat = src.Width - 1f; if (syFloat < 0f) syFloat = 0f; if (syFloat > src.Height - 1f) syFloat = src.Height - 1f; var sxi = (int)sxFloat; var syi = (int)syFloat; var sxInv = Math.Min(sxi + 1, src.Width - 1); var syInv = Math.Min(syi + 1, src.Height - 1); var fx8 = (int)((sxFloat - sxi) * 256); var fxInv = 256 - fx8; var fy8 = (int)((syFloat - syi) * 256); var fyInv = 256 - fy8; var stride = src.Width; var p00 = (syi * stride + sxi) * 4; var p10 = (syi * stride + sxInv) * 4; var p01 = (syInv * stride + sxi) * 4; var p11 = (syInv * stride + sxInv) * 4; var px = src.BgraPixels; b = Bilinear(px[p00], px[p10], px[p01], px[p11], fx8, fxInv, fy8, fyInv); g = Bilinear(px[p00 + 1], px[p10 + 1], px[p01 + 1], px[p11 + 1], fx8, fxInv, fy8, fyInv); r = Bilinear(px[p00 + 2], px[p10 + 2], px[p01 + 2], px[p11 + 2], fx8, fxInv, fy8, fyInv); a = Bilinear(px[p00 + 3], px[p10 + 3], px[p01 + 3], px[p11 + 3], fx8, fxInv, fy8, fyInv); } private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv) { var top = tl * fxInv + tr * fx8; var bot = bl * fxInv + br * fx8; return (top * fyInv + bot * fy8 + 32768) >> 16; } }