From ac60a26d8236d032c6faf63ab7b3686c5cc87c89 Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 13 Aug 2026 09:29:58 -0700 Subject: [PATCH] =?UTF-8?q?TASK=204=20ship=20step=205.5:=20social=20bar=20?= =?UTF-8?q?bug=20fixes=20+=20bar=20on=20the=20live=20output=20=E2=80=94=20?= =?UTF-8?q?direction-snap=20drag=20(SocialBarSnap.Decide=20+=20ClearValue,?= =?UTF-8?q?=20local=20Canvas.Top=20was=20overriding=20the=20binding),=20fe?= =?UTF-8?q?diverse=20nodeinfo=20subdomain=20probing=20+=20load-time=20heal?= =?UTF-8?q?=20(settable=20FediverseSoftware),=20SocialBarRenderer=20strip?= =?UTF-8?q?=20blitted=20above=20the=20flash=20via=20a=20re-read=20FramePum?= =?UTF-8?q?p=20socialBar=20seam=20=E2=80=94=20155=20tests=20passing,=200?= =?UTF-8?q?=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 46 ++++++--- MainWindow.xaml.cs | 33 ++++-- Models/Socials.cs | 36 ++++++- Services/Compositor/SceneCompositor.cs | 27 +++-- Services/Compositor/SocialBarRenderer.cs | 107 ++++++++++++++++++++ Services/Encoder/FramePump.cs | 20 +++- Services/SocialValidator.cs | 65 ++++++++++-- Services/index.md | 7 +- TASKS.md | 40 ++++++++ ViewModels/MainViewModel.cs | 84 ++++++++++++++- ai.md | 29 +++++- ytLive.Tests/FramePumpTests.cs | 49 ++++++++- ytLive.Tests/SceneCompositorTests.cs | 37 +++++++ ytLive.Tests/SocialBarTests.cs | 99 ++++++++++++++++++ ytLive.Tests/SocialValidatorTests.cs | 33 ++++++ ytLive.Tests/SocialsDialogViewModelTests.cs | 6 ++ 16 files changed, 666 insertions(+), 52 deletions(-) create mode 100644 Services/Compositor/SocialBarRenderer.cs diff --git a/HANDOFF.md b/HANDOFF.md index 9294550..e499a15 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,18 +7,29 @@ ## Session state (last updated: 2026-08-13) -- **Branch:** `main`. TASK 4 ship step 5 (the live frame pipeline) is **shipped - and committed** — the `FramePump` frame producer - (`Services/Encoder/FramePump.cs`), `ScreenCaptureManager.GetLatestFrame(key)`, - the `MainViewModel` resolver/option-builders/pump lifecycle wiring, `FramePumpTests` - (7) + the `GetLatestFrame` test (1), and the memory updates (TASKS.md, ai.md, - Services/index.md — the previous session left `Services/index.md` stale and - HANDOFF unrewritten; both were fixed before the commit). Build **0 warnings**, - **147 tests passing**. A Windows patch reboot killed the prior session right - after the work was verified but before the commit. -- **Finished this session:** ship step 5 (re-verified: 0 warnings, 147/147 pass), - the `Services/index.md` FramePump row + de-stale'd encoder rows, and this - HANDOFF rewrite. +- **Branch:** `main`. TASK 4 **ship step 5.5 is implemented, tested, and staged for + commit** — the social bar bug-fix branch (drag snap + fediverse heal + the bar on + the live output). Uncommitted: the feature work, tests (+8 → 155), and the memory + updates in TASKS.md / ai.md / Services/index.md / this file. Next action is + `git add` + commit + push (feature + its memory docs in ONE commit, per the + working rules). +- **Finished this session:** both reported bugs diagnosed to root cause and fixed; + compositor rendering of the social bar added per user approval. + - **Bug 1 — drag didn't snap:** `Canvas.SetTop` set a local value that permanently + overrides `{Binding SocialBarTop}`; the release-time snap could never win. Fixed + with `SocialBarSnap.Decide` (direction-snap during drag, ±6px deadzone) + + `bar.ClearValue(Canvas.TopProperty)` on release. + - **Bug 2 — Mastodon showed the generic honeycomb:** `@gramps@llamachile.tube` + had `Software = NULL` (nodeinfo only ever asked of the landing-page domain). + Fixed via subdomain probing (`mastodon.` → `social.` → … candidates, ~15s + budget), a load-time heal (`MainViewModel.HealFediverseSoftwareAsync`), and a + settable `SocialEntry.FediverseSoftware` that raises `LogoData`. + - **Compositor bar:** `Services/Compositor/SocialBarRenderer.cs` (new, WPF + RenderTargetBitmap → straight-alpha strip), `SceneCompositor` blits it above + the flash, `FramePump` gains a re-read-each-frame `socialBar:` seam, + `MainViewModel` owns the strip frame. +- **Verified:** Windows-host build **0 warnings**; **155/155 tests passing** + (147 baseline + 8 new). Full commit contents pending. - **Landmines:** - The pump reads the active scene on a background thread while the UI can still edit it — a concurrent-mutation exception is contained (logged + `Failed` + @@ -31,11 +42,18 @@ delay can run the first iteration synchronously on the caller's thread). - `StartAsync` never throws; the VM fires-and-forgets it. `Failed` while live flips `StreamStatus.Error` (minimal — real health surfacing is ship step 6). + - The heal runs off the UI thread and applies via the dispatcher; a config swap + mid-heal (dialog Save) can orphan the healed values on the old entries — harmless + (best-effort, re-healed next load). - Tests never instantiate `MainViewModel` directly except the round-clip integration test (a real `MainWindow`), which never goes live — keep it that way. -- **Next step:** TASK 4 ship step 6 — health stats: bind `FramePump.HealthUpdated` + - Sandbox can't reach outbound HTTPS — the subdomain-probe logic is verified via + stub-handler tests only, not against the real `mastodon.llamachile.tube`. +- **Next step:** commit + push ship step 5.5 (one commit incl. memory docs). Then + TASK 4 ship step 6 — health stats: bind `FramePump.HealthUpdated` (bitrate/FPS/duration) into the bottom bar. Nothing else queued — do not expand - the task queue on your own. + the task queue on your own. Optional, not queued: rewriting the healed entry's + `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps` (user must say the word). - **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`; OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`); diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 889140a..5f4691c 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -254,18 +254,20 @@ public partial class MainWindow : Window private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) => OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%"; - // ─── Social bar drag: vertical only, snaps to top/bottom on release ─── + // ─── Social bar drag: vertical only, direction-snaps to the top/bottom edge ─── private const double SocialBarBottomTop = 1040; private bool _isDraggingSocialBar; - private double _socialBarGrabOffsetY; + private double _socialBarDragStartY; + private SocialBarPosition? _socialBarDragDirection; private void SocialBar_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { var bar = (FrameworkElement)sender; _isDraggingSocialBar = true; + _socialBarDragDirection = null; var toCanvas = CanvasGrid.TransformToVisual(bar).Inverse; var p = toCanvas.Transform(e.GetPosition(bar)); - _socialBarGrabOffsetY = p.Y - Canvas.GetTop(bar); + _socialBarDragStartY = p.Y; bar.CaptureMouse(); e.Handled = true; } @@ -276,7 +278,17 @@ public partial class MainWindow : Window var bar = (FrameworkElement)sender; var toCanvas = CanvasGrid.TransformToVisual(bar).Inverse; var p = toCanvas.Transform(e.GetPosition(bar)); - Canvas.SetTop(bar, Math.Clamp(p.Y - _socialBarGrabOffsetY, 0, SocialBarBottomTop)); + + // Derive the drag direction once it commits (past the deadzone) and keep it + // for the rest of the drag — the bar snaps to the edge it's being dragged + // toward and is never left mid-screen. + var direction = _socialBarDragDirection + ?? SocialBarSnap.Decide(p.Y - _socialBarDragStartY); + if (direction != null) + { + _socialBarDragDirection = direction; + Canvas.SetTop(bar, direction == SocialBarPosition.Top ? 0 : SocialBarBottomTop); + } e.Handled = true; } @@ -284,11 +296,18 @@ public partial class MainWindow : Window { if (!_isDraggingSocialBar) return; var bar = (FrameworkElement)sender; - var top = Canvas.GetTop(bar); bar.ReleaseMouseCapture(); _isDraggingSocialBar = false; - var position = top <= SocialBarBottomTop / 2.0 ? SocialBarPosition.Top : SocialBarPosition.Bottom; - _viewModel.SetSocialBarPosition(position); + + var direction = _socialBarDragDirection + ?? (Canvas.GetTop(bar) <= SocialBarBottomTop / 2.0 + ? SocialBarPosition.Top : SocialBarPosition.Bottom); + + // ClearValue removes the local value Canvas.SetTop applied during the drag — + // a local value permanently overrides the {Binding SocialBarTop}, which is + // why the release snap never used to show. Clearing re-engages the binding. + bar.ClearValue(Canvas.TopProperty); + _viewModel.SetSocialBarPosition(direction); e.Handled = true; } diff --git a/Models/Socials.cs b/Models/Socials.cs index f59b9fb..890654a 100644 --- a/Models/Socials.cs +++ b/Models/Socials.cs @@ -19,8 +19,22 @@ public sealed class SocialEntry : INotifyPropertyChanged public string Handle { get; init; } = string.Empty; public string ProfileUrl { get; init; } = string.Empty; - /// Fediverse instance software (nodeinfo) for = Fediverse. - public string? FediverseSoftware { get; init; } + private string? _fediverseSoftware; + + /// Fediverse instance software (nodeinfo) for = Fediverse. + /// Settable so the load-time heal can fill a missing name and the bar icon + /// updates in place. + public string? FediverseSoftware + { + get => _fediverseSoftware; + set + { + if (_fediverseSoftware == value) return; + _fediverseSoftware = value; + Raise(nameof(FediverseSoftware)); + Raise(nameof(LogoData)); + } + } public string ServiceName => Service.ToString(); public string LogoData => Service == SocialService.Fediverse @@ -238,7 +252,7 @@ public static class SocialServiceIcons } /// True when the input is a fediverse handle (@user@domain); out the parts. - private static bool TryParseFediverse(string input, out string user, out string domain) + public static bool TryParseFediverse(string input, out string user, out string domain) { user = domain = string.Empty; if (string.IsNullOrWhiteSpace(input) || input[0] != '@') return false; @@ -249,3 +263,19 @@ public static class SocialServiceIcons return true; } } + +/// +/// Direction-based snap for the social bar drag (preview): a drag toward the top +/// edge snaps the bar to the top, toward the bottom snaps it to the bottom — the +/// bar is never left mid-screen. Returns null inside the deadzone so a drag that +/// hasn't committed to a direction doesn't flip the bar. +/// +public static class SocialBarSnap +{ + public static SocialBarPosition? Decide(double deltaY, double deadzone = 6) + { + if (deltaY <= -deadzone) return SocialBarPosition.Top; + if (deltaY >= deadzone) return SocialBarPosition.Bottom; + return null; + } +} diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs index 735c27e..fa09ea0 100644 --- a/Services/Compositor/SceneCompositor.cs +++ b/Services/Compositor/SceneCompositor.cs @@ -18,13 +18,16 @@ public sealed class SceneCompositor /// Composite into the tier's output frame. Layer order (back → /// front): live backdrop (the scene's IsBackdrop source) → background image → /// visible elements (z-order = Elements order, mirroring the XAML DataTemplate) → - /// branding flash. Transparent regions read opaque black. + /// branding flash → social bar (a global overlay; spans the master width at + /// ). Transparent regions read opaque black. /// public VideoFrame Render( Scene scene, Func frameFor, VideoFrame? flashFrame, - CompositorOptions options) + CompositorOptions options, + VideoFrame? socialBarFrame = null, + int socialBarTop = 0) { if (scene == null) throw new ArgumentNullException(nameof(scene)); if (frameFor == null) throw new ArgumentNullException(nameof(frameFor)); @@ -79,7 +82,10 @@ public sealed class SceneCompositor } if (flashFrame != null) - BlitFlash(buffer, cropW, cropH, options, flashFrame); + BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0); + + if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0) + BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop); return StretchMath.BilinearScale( new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight); @@ -166,17 +172,20 @@ public sealed class SceneCompositor } } - /// 1:1 copy of the master-sized branding flash, cropped to the active source rect. - private static void BlitFlash(byte[] dst, int dstW, int dstH, CompositorOptions options, VideoFrame flash) + /// 1:1 copy of a master-sized overlay (flash, social bar) cropped to the + /// active source rect. The overlay is positioned in master space by (sx0, sy0). + private static void BlitOverlay( + byte[] dst, int dstW, int dstH, CompositorOptions options, + VideoFrame overlay, int sx0, int sy0) { for (var y = 0; y < dstH; y++) { for (var x = 0; x < dstW; x++) { - var sx = x + options.SourceRectX; - var sy = y + options.SourceRectY; - if (sx >= flash.Width || sy >= flash.Height) continue; - var sample = StretchMath.SampleBgra(flash.BgraPixels, flash.Width, flash.Height, sx, sy); + var sx = x + options.SourceRectX - sx0; + var sy = y + options.SourceRectY - sy0; + if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue; + var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy); BlendPixel(dst, (y * dstW + x) * 4, sample, 1f); } } diff --git a/Services/Compositor/SocialBarRenderer.cs b/Services/Compositor/SocialBarRenderer.cs new file mode 100644 index 0000000..fcdec72 --- /dev/null +++ b/Services/Compositor/SocialBarRenderer.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Effects; +using System.Windows.Media.Imaging; +using System.Windows.Shapes; +using ytLive.Models; + +namespace ytLive.Services.Compositor; + +/// +/// Rasterizes the global social bar into a transparent BGRA8 strip the output +/// compositor overlays onto the master frame (top or bottom edge). Replicates the +/// preview's bar DataTemplate in code — one Path (logo) + one TextBlock (handle) +/// per entry, white on transparent, green glow — then software-renders it via +/// RenderTargetBitmap and converts Pbgra32 (premultiplied) to straight-alpha +/// BGRA for the compositor. WPF glue like : the +/// compositor core itself stays pure byte-math. Renders on the UI thread only; +/// the returned frame is immutable afterwards, so the frame pump may read it from +/// any thread. +/// +public static class SocialBarRenderer +{ + /// Icon size + the preview's 8px top/bottom margins. + private const int ContentHeight = 40; + + /// Room for the green DropShadowEffect blur to bleed past the content. + private const int GlowPad = 24; + + /// Master-frame width — the bar spans the full 1920px output. + public const int Width = 1920; + + /// Returns the strip frame, or null when there is nothing to render. + public static VideoFrame? Render(IEnumerable? entries) + { + var list = entries?.Where(e => e != null).ToList(); + if (list == null || list.Count == 0) return null; + + var height = ContentHeight + GlowPad * 2; + + var row = new StackPanel { Orientation = Orientation.Horizontal }; + foreach (var entry in list) + { + var item = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 20, 0), + }; + item.Children.Add(new Path + { + Data = Geometry.Parse(entry.LogoData), + Fill = Brushes.White, + Width = 24, + Height = 24, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + }); + item.Children.Add(new TextBlock + { + Text = entry.Handle, + Foreground = Brushes.White, + FontSize = 14, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0), + MaxWidth = 200, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + row.Children.Add(item); + } + + // The glow is baked in so the output bar matches the preview's green halo. + var container = new Grid + { + Width = Width, + Height = height, + Background = Brushes.Transparent, + Effect = new DropShadowEffect { Color = Color.FromRgb(0x2e, 0xcc, 0x71), BlurRadius = 18, ShadowDepth = 0, Opacity = 0.9 }, + }; + var centered = new Grid { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, GlowPad, 0, 0) }; + centered.Children.Add(row); + container.Children.Add(centered); + + container.Measure(new Size(Width, height)); + container.Arrange(new Rect(0, 0, Width, height)); + + var bitmap = new RenderTargetBitmap(Width, height, 96, 96, PixelFormats.Pbgra32); + bitmap.Render(container); + + var pixels = new byte[Width * height * 4]; + bitmap.CopyPixels(pixels, Width * 4, 0); + + // Pbgra32 is premultiplied — unpremultiply so the compositor's straight-alpha + // source-over blend doesn't darken the logo edges with a halo. + for (var i = 0; i < pixels.Length; i += 4) + { + var a = pixels[i + 3]; + if (a == 0 || a == 255) continue; + pixels[i] = (byte)(pixels[i] * 255 / a); + pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a); + pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a); + } + + return new VideoFrame(Width, height, pixels); + } +} diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs index 91513b1..ce4190a 100644 --- a/Services/Encoder/FramePump.cs +++ b/Services/Encoder/FramePump.cs @@ -28,6 +28,7 @@ public sealed class FramePump : IDisposable private readonly Func _encoderFactory; private readonly Action? _log; private readonly Func _pacingDelay; + private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar; private readonly SceneCompositor _compositor = new(); private readonly object _gate = new(); @@ -49,7 +50,8 @@ public sealed class FramePump : IDisposable Func encoderOptions, Func encoderFactory, Action? log = null, - Func? pacingDelay = null) + Func? pacingDelay = null, + Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null) { _sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider)); _frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver)); @@ -58,6 +60,7 @@ public sealed class FramePump : IDisposable _encoderFactory = encoderFactory ?? throw new ArgumentNullException(nameof(encoderFactory)); _log = log; _pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct)); + _socialBar = socialBar; } public bool IsRunning { get; private set; } @@ -212,7 +215,20 @@ public sealed class FramePump : IDisposable var scene = _sceneProvider(); if (scene != null) { - var frame = _compositor.Render(scene, _frameResolver, null, _compositorOptions()); + var compositorOptions = _compositorOptions(); + VideoFrame? socialBarFrame = null; + var socialBarTop = 0; + if (_socialBar != null) + { + var (barFrame, position) = _socialBar(); + socialBarFrame = barFrame; + if (barFrame != null) + socialBarTop = position == SocialBarPosition.Top + ? 0 + : compositorOptions.SourceRectHeight - barFrame.Height; + } + var frame = _compositor.Render( + scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop); IFfmpegEncoder? encoder; lock (_gate) encoder = _encoder; if (encoder == null) break; // _encoder is only cleared after the loop ends; defensive diff --git a/Services/SocialValidator.cs b/Services/SocialValidator.cs index eb38b66..5b32cc3 100644 --- a/Services/SocialValidator.cs +++ b/Services/SocialValidator.cs @@ -23,6 +23,11 @@ public sealed class SocialLookupResult public interface ISocialValidator { Task LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct); + + /// Best-effort nodeinfo software name (mastodon/peertube/...) for a + /// fediverse domain, or null when it can't be resolved. The load-time heal + /// uses this to fix a bar entry whose stored software is missing. + Task ResolveFediverseSoftwareAsync(string domain, CancellationToken ct); } /// @@ -35,14 +40,19 @@ public interface ISocialValidator public sealed class HttpSocialValidator : ISocialValidator { private readonly HttpClient _client; + private readonly Action? _log; - public HttpSocialValidator(HttpClient? client = null) + public HttpSocialValidator(HttpClient? client = null, Action? log = null) { _client = client ?? new HttpClient(); _client.DefaultRequestHeaders.UserAgent.ParseAdd( "Mozilla/5.0 (compatible; ytLlive social validator)"); + _log = log; } + public Task ResolveFediverseSoftwareAsync(string domain, CancellationToken ct) + => TryFetchFediverseSoftwareAsync(domain, ct); + public async Task LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct) { var input = handleOrUrl.Trim(); @@ -127,32 +137,67 @@ public sealed class HttpSocialValidator : ISocialValidator /// nodeinfo link, read `software.name`. If the identity domain is itself a /// redirect (e.g. a YunoHost domain whose default app lives on a subdomain), /// the bare root 302s to the real instance — follow it and ask that host. - /// Failures return null — validation still succeeds, the icon just falls - /// back to the generic fediverse glyph. + /// If neither answers, probe well-known subdomains (mastodon.example.com) + /// so a landing-page domain still resolves its real instance. The whole + /// resolution is bounded by a ~15s budget. Failures return null — validation + /// still succeeds, the icon just falls back to the generic fediverse glyph. /// private async Task TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct) { try { - var software = await FetchSoftwareNameAsync(domain, ct); + using var budget = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(ct); + budget.CancelAfter(TimeSpan.FromSeconds(15)); + + var software = await FetchSoftwareNameAsync(domain, budget.Token); if (software != null) return software; - var resolved = await ResolveInstanceHostAsync(domain, ct); - if (resolved == null - || string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase)) - return null; - return await FetchSoftwareNameAsync(resolved, ct); + var resolved = await ResolveInstanceHostAsync(domain, budget.Token); + if (resolved != null + && !string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase)) + { + software = await FetchSoftwareNameAsync(resolved, budget.Token); + if (software != null) return software; + } + + foreach (var sub in FediverseSubdomainCandidates) + { + var host = $"{sub}.{domain}"; + software = await FetchSoftwareNameAsync(host, budget.Token); + if (software != null) + { + _log?.Invoke($"SocialValidator: fediverse software '{software}' for '{domain}' resolved via {host}"); + return software; + } + } + _log?.Invoke($"SocialValidator: no nodeinfo found for '{domain}'"); + return null; } catch (System.OperationCanceledException) when (ct.IsCancellationRequested) { return null; // caller checks ct.IsCancellationRequested and reports Canceled } - catch + catch (System.OperationCanceledException) { + _log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' timed out"); + return null; + } + catch (System.Exception ex) + { + _log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' failed: {ex.Message}"); return null; } } + /// Common instance subdomains, probed in order when the identity + /// domain itself doesn't serve nodeinfo (a landing page or SSO gate in front + /// of a self-hosted instance). + private static readonly string[] FediverseSubdomainCandidates = + { + "mastodon", "social", "fediverse", "tube", "peertube", + "pixelfed", "lemmy", "pleroma", "misskey", "gotosocial", "fedi", "instance", + }; + private async Task FetchSoftwareNameAsync(string domain, CancellationToken ct) { var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct); diff --git a/Services/index.md b/Services/index.md index 1b2586e..830c58d 100644 --- a/Services/index.md +++ b/Services/index.md @@ -27,12 +27,13 @@ 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:` / `window:` / `picker:` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) | -| `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. Pure and WPF-free: frames injected via a `Func` 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/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` 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) | | `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) | | `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it | -| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host instead. Constructor takes optional `HttpClient` for tests | +| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action` log for tests | +| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread | | `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) | | `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) | | `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` | @@ -41,7 +42,7 @@ External-facing logic: YouTube API, persistence. See | `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP | | `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` | | `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) | -| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. See `ai.md` "Live frame pipeline" | +| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. See `ai.md` "Live frame pipeline" | | `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 | | `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`; runs only while live. The app consumes this seam; tests inject hermetic fakes | | `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels | diff --git a/TASKS.md b/TASKS.md index 7edd361..0e6298b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -475,6 +475,46 @@ added, `MainViewModel` wired end-to-end (resolver + both option builders + pump the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation exception is contained (logged + `Failed` + pump stops) rather than crashing. +#### Ship step 5.5 — Social bar bug fixes + the bar on the live output (2026-08-13) + +**Bug 1 — drag doesn't snap (root cause found):** `SocialBar_PreviewMouseMove` set `Canvas.SetTop(bar, …)` +with a local value, which permanently overrides the `Canvas.Top="{Binding SocialBarTop}"` binding — the +release-time `SetSocialBarPosition` → `PropertyChanged(SocialBarTop)` could never beat it, so the bar stayed +wherever it was dropped. Fixed by direction-snapping **during** the drag (`SocialBarSnap.Decide` — deadzone +±6px, once the direction commits the bar rides the edge it's dragged toward, never mid-screen) and on release +`bar.ClearValue(Canvas.TopProperty)` re-engages the binding before `SetSocialBarPosition`. + +**Bug 2 — Mastodon showed the generic 7-star honeycomb (root cause found):** the DB row +`@gramps@llamachile.tube` had `Software = NULL` — nodeinfo was only ever resolved against the identity domain +(`llamachile.tube`, a landing page), never probed for the real instance at `mastodon.llamachile.tube`. +Fixed on three fronts: `HttpSocialValidator` now **probes well-known subdomains** (mastodon. → social. → +… `FediverseSubdomainCandidates`) when the identity domain and its redirect both come up empty, under a +~15s linked-CTS budget, with an optional `Action` log; `MainViewModel` **heals** any fediverse entry +missing a software name on layout load (`HealFediverseSoftwareAsync` — static, testable; instance wrapper +runs it off the UI thread, applies via the dispatcher, saves); `SocialEntry.FediverseSoftware` is now +**settable** and raises `PropertyChanged` for `LogoData`, so the heal updates the icon in place. If the user +later re-saves the entry with the icon fixed, the name persists with it. + +**Compositor rendering (user-approved scope):** the social bar now appears **on the live output**, not just +the preview — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a transparent straight-alpha +BGRA strip (1920-wide, 40px content + 24px glow pad, green `#2ecc71` glow baked in) via `RenderTargetBitmap` +(WPF glue like `StaticPixelCache`; the compositor core stays pure). `SceneCompositor.Render` takes optional +`socialBarFrame` + `socialBarTop` (master space) and blits it **last — above the branding flash** (the old +`BlitFlash` generalized to offset `BlitOverlay`). `FramePump` gains a `socialBar:` seam +(`Func<(VideoFrame?, SocialBarPosition)>`, re-read every frame) and places the bar at `0` or +`SourceRectHeight − bar height`. `MainViewModel` owns the frame (`RenderSocialBarFrame`, re-rendered on +load/save/notify) and feeds the seam. + +**Tests (+8 → 155 passing, 0 warnings):** `SocialBarSnap.Decide` units (direction + deadzone + custom +deadzone), settable `FediverseSoftware` updates `LogoData`, subdomain-probe unit + null-when-silent unit, +the branch's **one integration test** `Socials_HealMissingFediverseSoftware_RoundTripsThroughDb` +(temp-DB roundtrip: NULL software → healed via the validator → persisted), compositor bar overlay +(top/bottom + above-flash), `FramePump` bar pass-through (Top then flipped to Bottom mid-run — the seam is +re-read each frame), and both `ISocialValidator` fakes (`FakeValidator`/`BlockingValidator`) gained +`ResolveFediverseSoftwareAsync`. + +**Not included (say the word):** rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`. + --- ## TASK 5 — YouTube Live Stream Management diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 6569652..d7b9bff 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -80,6 +80,7 @@ public class MainViewModel : ViewModelBase private Webcam? _webcam; private string? _webcamError; private SocialsConfig? _socials; + private VideoFrame? _socialBarFrame; private readonly IMicrophoneEnumerator _microphoneEnumerator; // Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback, @@ -872,7 +873,8 @@ public class MainViewModel : ViewModelBase compositorOptions: BuildCompositorOptions, encoderOptions: BuildEncoderOptions, encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()), - log: message => AppLog.Write(message)); + log: message => AppLog.Write(message), + socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom)); _framePump.Failed += OnFramePumpFailed; LoadLayout(); @@ -966,6 +968,8 @@ public class MainViewModel : ViewModelBase ReacquireWebcam(); ReacquireScreenCaptures(); _socials = _layoutStore.Socials; + RenderSocialBarFrame(); + HealFediverseSoftwareInBackground(); OnPropertyChanged(nameof(HasSocials)); OnPropertyChanged(nameof(SocialBarVisible)); OnPropertyChanged(nameof(SocialBarDotBrush)); @@ -1902,6 +1906,83 @@ public class MainViewModel : ViewModelBase // ─── Social bar ─── + /// + /// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every + /// fediverse entry that doesn't have one, so the bar shows the instance's real + /// logo instead of the generic fediverse glyph. Best-effort: a failed resolution + /// leaves the glyph untouched. Returns handle → software for what was resolved — + /// the caller applies + persists (the app hops to the dispatcher; a test applies + /// directly). + /// + public static async Task> HealFediverseSoftwareAsync( + SocialsConfig socials, + ISocialValidator validator, + Action? log = null) + { + var resolved = new Dictionary(); + if (socials == null || validator == null) return resolved; + foreach (var entry in socials.Entries) + { + if (entry.Service != SocialService.Fediverse + || !string.IsNullOrWhiteSpace(entry.FediverseSoftware)) + continue; + if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain)) + continue; + var software = await validator.ResolveFediverseSoftwareAsync( + domain, System.Threading.CancellationToken.None); + if (string.IsNullOrWhiteSpace(software)) continue; + resolved[entry.Handle] = software; + log?.Invoke($"Socials heal: {entry.Handle} → {software}"); + } + return resolved; + } + + /// Runs the heal off the UI thread and applies + saves any result. + private void HealFediverseSoftwareInBackground() + { + var socials = _socials; + if (socials == null) return; + _ = Task.Run(async () => + { + try + { + var resolved = await HealFediverseSoftwareAsync( + socials, _socialValidator, m => AppLog.Write(m)); + if (resolved.Count == 0) return; + await Application.Current.Dispatcher.InvokeAsync(() => + { + var current = _socials; + if (current == null) return; + var applied = false; + foreach (var entry in current.Entries) + { + if (!resolved.TryGetValue(entry.Handle, out var software)) continue; + if (entry.FediverseSoftware == software) continue; + entry.FediverseSoftware = software; + applied = true; + } + if (applied) NotifySocialsChanged(); // re-renders the bar + saves + }); + } + catch (Exception ex) + { + AppLog.Write($"Socials heal failed: {ex.Message}"); + } + }); + } + + /// + /// Re-rasterizes the social bar strip the output compositor overlays. UI thread + /// only (WPF rendering); the resulting frame is immutable, so the frame pump may + /// read it from its own thread. Null when the bar is off or empty. + /// + private void RenderSocialBarFrame() + { + _socialBarFrame = _socials != null && _socials.BarEnabled + ? SocialBarRenderer.Render(_socials.Entries) + : null; + } + /// /// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate, /// validation). On save the working copy replaces . @@ -1925,6 +2006,7 @@ public class MainViewModel : ViewModelBase private void NotifySocialsChanged() { + RenderSocialBarFrame(); OnPropertyChanged(nameof(HasSocials)); OnPropertyChanged(nameof(SocialBarVisible)); OnPropertyChanged(nameof(SocialBarDotBrush)); diff --git a/ai.md b/ai.md index 54e3eca..86f558b 100644 --- a/ai.md +++ b/ai.md @@ -407,11 +407,19 @@ seam:** `Func`, `Func` resolver, `Func?` seam, re-read **every frame** (so a + mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs` + (WPF glue — WPF glue here is fine because the strip is rendered once per config change on the UI thread, + the resulting immutable frame is then composited pure-CPU by `SceneCompositor`), and `SceneCompositor.Render` + blits it **last — above the branding flash** at `socialBarTop` (0 = top, `SourceRectHeight − barHeight` = + bottom) in master space. `MainViewModel` owns the frame (`_socialBarFrame`, rebuilt by `RenderSocialBarFrame` + on load/save/`NotifySocialsChanged`). - Known consideration: the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation exception is contained (logged + `Failed` + the pump stops) rather than crashing. The background thread + video pipeline is the new reality since this step. -### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md) +### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md; bar bug-fix branch 2026-08-13) A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the output and carries the creator's social links — content-sized, centered, GREEN glow when ON, top/bottom @@ -437,6 +445,25 @@ honeycomb fallback — Simple Icons CC0 path data, initials badges gone); nodein `CancellationToken` (dialog VM owns a CTS; Cancel/X/Save abort in-flight lookups, canceled continuations never touch slot state). +**Bar bug-fix branch (2026-08-13) — three changes:** + +1. **Drag now direction-snaps** — a local `Canvas.SetTop` value permanently overrides the + `{Binding SocialBarTop}` (a binding can never win over a local value), so the old drag left the bar + wherever it was dropped. Now `SocialBarSnap.Decide` (pure, in `Models/Socials.cs`) commits a direction + once the drag passes the ±6px deadzone and rides the edge it's dragged toward; on release + `bar.ClearValue(Canvas.TopProperty)` re-engages the binding before `SetSocialBarPosition`. +2. **Fediverse software self-heal** — the DB row `@gramps@llamachile.tube` had `Software = NULL` because + nodeinfo was only ever asked of the identity domain (a landing page; the real instance is + `mastodon.llamachile.tube`). `HttpSocialValidator.ResolveFediverseSoftwareAsync` now **probes + well-known subdomains** (`mastodon.` → `social.` → … `FediverseSubdomainCandidates`) when the identity + domain and its redirect both come up empty, under a ~15s linked-CTS budget. `MainViewModel` runs the + static `HealFediverseSoftwareAsync` off the UI thread on layout load, applies matches via the dispatcher, + and saves; `SocialEntry.FediverseSoftware` is settable and raises `LogoData`, so the icon updates in place. +3. **The bar renders on the live output** — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a + transparent straight-alpha BGRA strip (1920-wide, 40px content + 24px glow pad, the green glow baked in, + Pbgra32→straight-alpha unpremultiply) and the compositor blits it above the flash (see "Live frame + pipeline"). + ### Licensing — do not violate (GA = paid product; see `THIRD-PARTY-NOTICES.txt`) This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT diff --git a/ytLive.Tests/FramePumpTests.cs b/ytLive.Tests/FramePumpTests.cs index f883d3a..3b6ed5c 100644 --- a/ytLive.Tests/FramePumpTests.cs +++ b/ytLive.Tests/FramePumpTests.cs @@ -66,7 +66,8 @@ public class FramePumpTests private static FramePump NewPump(FakeEncoder encoder, Func? options = null, Func? scene = null, Func? resolve = null, - List? log = null) + List? log = null, + Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null) { return new FramePump( sceneProvider: scene ?? (() => BackdropScene()), @@ -83,7 +84,8 @@ public class FramePumpTests }), encoderFactory: () => encoder, log: log != null ? m => log.Add(m) : null, - pacingDelay: async (_, _) => await Task.Yield()); // deterministic: no real waits + pacingDelay: async (_, _) => await Task.Yield(), // deterministic: no real waits + socialBar: socialBar); } private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b) @@ -163,6 +165,49 @@ public class FramePumpTests Assert.False(pump.IsRunning); } + [Fact] + public async Task Start_WithSocialBarSeam_PlacesBarAtTopThenBottomEdge() + { + var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0); + var bar = new VideoFrame(64, 8, new byte[64 * 8 * 4]); + Array.Fill(bar.BgraPixels, (byte)255); // opaque white strip + + var encoder = new FakeEncoder(); + var position = SocialBarPosition.Top; + using var pump = NewPump(encoder, + resolve: e => e is Source { IsBackdrop: true } ? red : null, + socialBar: () => (bar, position)); + + await pump.StartAsync(); + await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.NotEmpty(encoder.Frames); + AssertColor(encoder.Frames[0], 0, 0, 255, 255, 255); // bar at the top edge + + // Flip to Bottom: the pump re-reads the seam each frame, so a later frame + // lands the bar at the bottom edge (SourceRectHeight - bar height) and the + // top corner clears back to backdrop. + position = SocialBarPosition.Bottom; + VideoFrame? flipped = null; + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline && flipped == null) + { + for (var i = 1; i < encoder.Frames.Count; i++) + { + var f = encoder.Frames[i]; + if (f.BgraPixels[2] == 255 && f.BgraPixels[1] == 0 && f.BgraPixels[0] == 0) + { + flipped = f; + break; + } + } + if (flipped == null) await Task.Delay(10); + } + Assert.NotNull(flipped); + AssertColor(flipped!, 0, 47, 255, 255, 255); // bar sits on the bottom edge + + await pump.StopAsync(); + } + [Fact] public async Task Start_EncoderThrows_RaisesFailed_AndDisposes() { diff --git a/ytLive.Tests/SceneCompositorTests.cs b/ytLive.Tests/SceneCompositorTests.cs index bd39bd4..14a6627 100644 --- a/ytLive.Tests/SceneCompositorTests.cs +++ b/ytLive.Tests/SceneCompositorTests.cs @@ -180,6 +180,43 @@ public class SceneCompositorTests // untouched corner stays pure red AssertColor(output, 0, 0, 255, 0, 0); } + + [Fact] + public void Composite_WithSocialBar_OverlaysAboveFlash_AtTopOrBottom() + { + var red = Solid(1920, 1080, 255, 0, 0); + var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" }; + var scene = new Scene { Name = "Live" }; + scene.Elements.Add(backdrop); + + var options = new CompositorOptions + { + SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080, + OutputWidth = 1920, OutputHeight = 1080, + }; + var compositor = new SceneCompositor(); + + // master-sized overlay: opaque green at (0,0), 50%-white at the center + var bar = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]); + var g = 0; + bar.BgraPixels[g] = 0; bar.BgraPixels[g + 1] = 255; bar.BgraPixels[g + 2] = 0; bar.BgraPixels[g + 3] = 255; + var w = (540 * 1920 + 960) * 4; + bar.BgraPixels[w] = 255; bar.BgraPixels[w + 1] = 255; bar.BgraPixels[w + 2] = 255; bar.BgraPixels[w + 3] = 128; + + // flash: opaque magenta at (0,0) — the bar must cover it + var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]); + flash.BgraPixels[0] = 255; flash.BgraPixels[1] = 0; flash.BgraPixels[2] = 255; flash.BgraPixels[3] = 255; + + var top = compositor.Render(scene, _ => red, flash, options, bar, 0); + AssertColor(top, 0, 0, 0, 255, 0); // bar above flash at the top-left + AssertColor(top, 960, 540, 255, 127, 127); // 50% white over red + AssertColor(top, 100, 100, 255, 0, 0); // empty overlay area: backdrop + + var bottom = compositor.Render(scene, _ => red, flash, options, bar, 1040); + AssertColor(bottom, 0, 1040, 0, 255, 0); // bar drawn at the bottom edge + AssertColor(bottom, 0, 1039, 255, 0, 0); // backdrop just above the bar + AssertColor(bottom, 960, 540, 255, 0, 0); // bar region moved away from center + } } public class StretchMathTests diff --git a/ytLive.Tests/SocialBarTests.cs b/ytLive.Tests/SocialBarTests.cs index 6ff9305..3f9d1c0 100644 --- a/ytLive.Tests/SocialBarTests.cs +++ b/ytLive.Tests/SocialBarTests.cs @@ -1,8 +1,11 @@ using System.IO; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Models; using ytLive.Services; +using ytLive.ViewModels; namespace ytLive.Tests; @@ -16,6 +19,102 @@ public class SocialBarTests return path; } + /// Resolves every fediverse domain as Mastodon — the heal test only + /// cares that a missing stored software name gets filled in and persisted. + private sealed class MastodonValidator : ISocialValidator + { + public Task ResolveFediverseSoftwareAsync(string domain, CancellationToken ct) + => Task.FromResult("mastodon"); + + public Task LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct) + => throw new System.NotSupportedException(); + } + + [Fact] + public void SocialBarSnap_Decide_DirectionAndDeadzone() + { + Assert.Equal(SocialBarPosition.Top, SocialBarSnap.Decide(-10)); + Assert.Equal(SocialBarPosition.Top, SocialBarSnap.Decide(-6)); + Assert.Equal(SocialBarPosition.Bottom, SocialBarSnap.Decide(10)); + Assert.Equal(SocialBarPosition.Bottom, SocialBarSnap.Decide(6)); + Assert.Null(SocialBarSnap.Decide(5)); + Assert.Null(SocialBarSnap.Decide(0)); + Assert.Null(SocialBarSnap.Decide(-5)); + } + + [Fact] + public void SocialBarSnap_Decide_HonorsCustomDeadzone() + { + Assert.Null(SocialBarSnap.Decide(10, 20)); + Assert.Equal(SocialBarPosition.Top, SocialBarSnap.Decide(-21, 20)); + } + + [Fact] + public void SocialEntry_FediverseSoftware_SettableUpdatesLogo() + { + var entry = new SocialEntry + { + Service = SocialService.Fediverse, + Handle = "@gramps@llamachile.tube", + ProfileUrl = "https://llamachile.tube/@gramps", + }; + var honeycomb = entry.LogoData; + entry.FediverseSoftware = "mastodon"; + Assert.Equal(SocialServiceIcons.LogoDataForFediverse("mastodon"), entry.LogoData); + Assert.NotEqual(honeycomb, entry.LogoData); + } + + /// The single integration test for this branch: a fediverse entry + /// persisted with a NULL software name is loaded, healed via the real validator + /// seam, and the resolved name is persisted back — surviving a second load. + [Fact] + public async Task Socials_HealMissingFediverseSoftware_RoundTripsThroughDb() + { + var path = TempDbPath(); + try + { + var socials = new SocialsConfig(); + socials.Entries.Add(new SocialEntry + { + Service = SocialService.Fediverse, + Handle = "@gramps@llamachile.tube", + ProfileUrl = "https://llamachile.tube/@gramps", + }); + using (var store = new LayoutStore(path)) + store.Save(new[] { new Scene { Name = "Live" } }, null, socials); + + SocialsConfig? loaded; + using (var store = new LayoutStore(path)) + { + store.Load(); + loaded = store.Socials; + Assert.NotNull(loaded); + Assert.Null(loaded!.Entries[0].FediverseSoftware); + } + + var healed = await MainViewModel.HealFediverseSoftwareAsync(loaded!, new MastodonValidator()); + Assert.Single(healed); + Assert.Equal("mastodon", healed["@gramps@llamachile.tube"]); + loaded.Entries[0].FediverseSoftware = healed["@gramps@llamachile.tube"]; + + using (var store = new LayoutStore(path)) + store.Save(new[] { new Scene { Name = "Live" } }, null, loaded); + + using (var store = new LayoutStore(path)) + { + store.Load(); + var again = store.Socials; + Assert.NotNull(again); + Assert.Equal("mastodon", again!.Entries[0].FediverseSoftware); + } + } + finally + { + SqliteConnection.ClearAllPools(); + if (File.Exists(path)) File.Delete(path); + } + } + [Fact] public void SocialsConfig_RoundTrip_PersistsEntriesAndBarSettings() { diff --git a/ytLive.Tests/SocialValidatorTests.cs b/ytLive.Tests/SocialValidatorTests.cs index 77a76d5..7d2a1cc 100644 --- a/ytLive.Tests/SocialValidatorTests.cs +++ b/ytLive.Tests/SocialValidatorTests.cs @@ -103,6 +103,39 @@ public class SocialValidatorTests Assert.Null(result.FediverseSoftware); } + [Fact] + public async Task FediverseSoftware_IdentityDomainSilent_ProbesWellKnownSubdomains() + { + // The identity domain is a silent landing page (no nodeinfo, no redirect); + // the real instance lives on mastodon. — the probe must find it. + var validator = Validator(new StubHandler(request => + { + var host = request.RequestUri!.Host; + if (host == "mastodon.llamachile.tube") + { + if (request.RequestUri.AbsolutePath.StartsWith("/.well-known")) + return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://mastodon.llamachile.tube/nodeinfo/2.0" } } }); + if (request.RequestUri.AbsolutePath.StartsWith("/nodeinfo")) + return Json(new { software = new { name = "mastodon", version = "4.6.3" } }); + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + })); + + var software = await validator.ResolveFediverseSoftwareAsync("llamachile.tube", CancellationToken.None); + + Assert.Equal("mastodon", software); + } + + [Fact] + public async Task FediverseSoftware_NoSubdomainAnswers_ReturnsNull() + { + var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound))); + + var software = await validator.ResolveFediverseSoftwareAsync("silent.example", CancellationToken.None); + + Assert.Null(software); + } + [Fact] public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled() { diff --git a/ytLive.Tests/SocialsDialogViewModelTests.cs b/ytLive.Tests/SocialsDialogViewModelTests.cs index 573ba70..1d03f1c 100644 --- a/ytLive.Tests/SocialsDialogViewModelTests.cs +++ b/ytLive.Tests/SocialsDialogViewModelTests.cs @@ -15,6 +15,9 @@ public class SocialsDialogViewModelTests { public int LookupCount { get; private set; } + public Task ResolveFediverseSoftwareAsync(string domain, CancellationToken ct) + => Task.FromResult("mastodon"); + public Task LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct) { LookupCount++; @@ -51,6 +54,9 @@ public class SocialsDialogViewModelTests public Task LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct) => Gate.Task; + + public Task ResolveFediverseSoftwareAsync(string domain, CancellationToken ct) + => Task.FromResult(null); } private sealed class SignInFake