TASK 4 ship step 5.5: social bar bug fixes + bar on the live output — direction-snap drag (SocialBarSnap.Decide + ClearValue, local Canvas.Top was overriding the binding), fediverse nodeinfo subdomain probing + load-time heal (settable FediverseSoftware), SocialBarRenderer strip blitted above the flash via a re-read FramePump socialBar seam — 155 tests passing, 0 warnings
This commit is contained in:
+32
-14
@@ -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`);
|
||||
|
||||
+26
-7
@@ -254,18 +254,20 @@ public partial class MainWindow : Window
|
||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> 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;
|
||||
}
|
||||
|
||||
|
||||
+33
-3
@@ -19,8 +19,22 @@ public sealed class SocialEntry : INotifyPropertyChanged
|
||||
public string Handle { get; init; } = string.Empty;
|
||||
public string ProfileUrl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.</summary>
|
||||
public string? FediverseSoftware { get; init; }
|
||||
private string? _fediverseSoftware;
|
||||
|
||||
/// <summary>Fediverse instance software (nodeinfo) for <see cref="Service"/> = Fediverse.
|
||||
/// Settable so the load-time heal can fill a missing name and the bar icon
|
||||
/// updates in place.</summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>True when the input is a fediverse handle (@user@domain); out the parts.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,16 @@ public sealed class SceneCompositor
|
||||
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
|
||||
/// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
|
||||
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
|
||||
/// branding flash. Transparent regions read opaque black.
|
||||
/// branding flash → social bar (a global overlay; spans the master width at
|
||||
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
|
||||
/// </summary>
|
||||
public VideoFrame Render(
|
||||
Scene scene,
|
||||
Func<SceneElement, VideoFrame?> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>1:1 copy of the master-sized branding flash, cropped to the active source rect.</summary>
|
||||
private static void BlitFlash(byte[] dst, int dstW, int dstH, CompositorOptions options, VideoFrame flash)
|
||||
/// <summary>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).</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>RenderTargetBitmap</c> and converts Pbgra32 (premultiplied) to straight-alpha
|
||||
/// BGRA for the compositor. WPF glue like <see cref="StaticPixelCache"/>: 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.
|
||||
/// </summary>
|
||||
public static class SocialBarRenderer
|
||||
{
|
||||
/// <summary>Icon size + the preview's 8px top/bottom margins.</summary>
|
||||
private const int ContentHeight = 40;
|
||||
|
||||
/// <summary>Room for the green DropShadowEffect blur to bleed past the content.</summary>
|
||||
private const int GlowPad = 24;
|
||||
|
||||
/// <summary>Master-frame width — the bar spans the full 1920px output.</summary>
|
||||
public const int Width = 1920;
|
||||
|
||||
/// <summary>Returns the strip frame, or null when there is nothing to render.</summary>
|
||||
public static VideoFrame? Render(IEnumerable<SocialEntry>? 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);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Func<IFfmpegEncoder> _encoderFactory;
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _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?> encoderOptions,
|
||||
Func<IFfmpegEncoder> encoderFactory,
|
||||
Action<string>? log = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null)
|
||||
Func<TimeSpan, CancellationToken, Task>? 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
|
||||
|
||||
@@ -23,6 +23,11 @@ public sealed class SocialLookupResult
|
||||
public interface ISocialValidator
|
||||
{
|
||||
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -35,14 +40,19 @@ public interface ISocialValidator
|
||||
public sealed class HttpSocialValidator : ISocialValidator
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly Action<string>? _log;
|
||||
|
||||
public HttpSocialValidator(HttpClient? client = null)
|
||||
public HttpSocialValidator(HttpClient? client = null, Action<string>? log = null)
|
||||
{
|
||||
_client = client ?? new HttpClient();
|
||||
_client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (compatible; ytLlive social validator)");
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> TryFetchFediverseSoftwareAsync(domain, ct);
|
||||
|
||||
public async Task<SocialLookupResult> 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.
|
||||
/// </summary>
|
||||
private async Task<string?> 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))
|
||||
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;
|
||||
return await FetchSoftwareNameAsync(resolved, ct);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
private static readonly string[] FediverseSubdomainCandidates =
|
||||
{
|
||||
"mastodon", "social", "fediverse", "tube", "peertube",
|
||||
"pixelfed", "lemmy", "pleroma", "misskey", "gotosocial", "fedi", "instance",
|
||||
};
|
||||
|
||||
private async Task<string?> FetchSoftwareNameAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct);
|
||||
|
||||
+4
-3
@@ -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:<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) |
|
||||
| `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<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/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) |
|
||||
| `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<string>` 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<SceneElement, VideoFrame?>` 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<SceneElement, VideoFrame?>` 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 |
|
||||
|
||||
@@ -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<string>` 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
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static async Task<Dictionary<string, string>> HealFediverseSoftwareAsync(
|
||||
SocialsConfig socials,
|
||||
ISocialValidator validator,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
var resolved = new Dictionary<string, string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Runs the heal off the UI thread and applies + saves any result.</summary>
|
||||
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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void RenderSocialBarFrame()
|
||||
{
|
||||
_socialBarFrame = _socials != null && _socials.BarEnabled
|
||||
? SocialBarRenderer.Render(_socials.Entries)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
|
||||
/// validation). On save the working copy replaces <see cref="_socials"/>.
|
||||
@@ -1925,6 +2006,7 @@ public class MainViewModel : ViewModelBase
|
||||
|
||||
private void NotifySocialsChanged()
|
||||
{
|
||||
RenderSocialBarFrame();
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
OnPropertyChanged(nameof(SocialBarDotBrush));
|
||||
|
||||
@@ -407,11 +407,19 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
|
||||
background → `StaticPixelCache.Get(AssetId)`. `BuildCompositorOptions` rounds the VM's `OutputRect*`
|
||||
doubles to ints — the vertical 607.5 half-pixel crop rounds to a perfectly-centered **608** (`Math.Round`,
|
||||
ToEven); `BuildEncoderOptions` fills W×H/FPS/bitrate from the tier once the URL provider yields one.
|
||||
- **Social bar on the output (bar bug-fix branch):** the `FramePump` takes an optional
|
||||
`socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` 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
|
||||
|
||||
@@ -66,7 +66,8 @@ public class FramePumpTests
|
||||
|
||||
private static FramePump NewPump(FakeEncoder encoder, Func<EncoderOptions?>? options = null,
|
||||
Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
|
||||
List<string>? log = null)
|
||||
List<string>? 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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Resolves every fediverse domain as Mastodon — the heal test only
|
||||
/// cares that a missing stored software name gets filled in and persisted.</summary>
|
||||
private sealed class MastodonValidator : ISocialValidator
|
||||
{
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> Task.FromResult<string?>("mastodon");
|
||||
|
||||
public Task<SocialLookupResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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()
|
||||
{
|
||||
|
||||
@@ -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.<domain> — 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()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,9 @@ public class SocialsDialogViewModelTests
|
||||
{
|
||||
public int LookupCount { get; private set; }
|
||||
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> Task.FromResult<string?>("mastodon");
|
||||
|
||||
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
{
|
||||
LookupCount++;
|
||||
@@ -51,6 +54,9 @@ public class SocialsDialogViewModelTests
|
||||
|
||||
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
=> Gate.Task;
|
||||
|
||||
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
private sealed class SignInFake
|
||||
|
||||
Reference in New Issue
Block a user