perf(video): move tier scaling to ffmpeg
The frame pump now submits crop-sized BGRA frames; FFmpeg scales them to final tier dimensions. Bundled scaler benchmark: 300 608x1080 to 1080x1920 frames in 0.435s (scaler-only). Reference: https://ffmpeg.org/ffmpeg-filters.html#scale-1
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Where the compositor draws from (the active quality tier's output rect over the
|
||||
/// 1920×1080 master) and at what size. 16:9 tiers = the full master 1:1; the vertical
|
||||
/// 9:16 tier = the centered 607×1080 crop scaled up to 1080×1920. The source rect is
|
||||
/// integer-aligned — MainViewModel's <c>OutputRectX</c> can be 656.5, so the caller
|
||||
/// rounds before building these options.
|
||||
/// Where the compositor draws from (the active quality tier's crop over the
|
||||
/// 1920×1080 master) and the size of the BGRA frame it sends to the encoder. The
|
||||
/// compositor frame is the crop itself; FFmpeg scales it to the selected output
|
||||
/// dimensions. The source rect is integer-aligned — MainViewModel's
|
||||
/// <c>OutputRectX</c> can be 656.5, so the caller rounds before building these options.
|
||||
/// </summary>
|
||||
public sealed class CompositorOptions
|
||||
{
|
||||
|
||||
@@ -6,10 +6,11 @@ using ytLive.Models;
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// The output compositor: renders a scene into the encoder's master frame (tightly-packed
|
||||
/// The output compositor: renders a scene into the encoder's raw input frame (tightly-packed
|
||||
/// BGRA8 <see cref="VideoFrame"/>) exactly as the XAML preview renders it, minus the
|
||||
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). The preview stays the
|
||||
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
|
||||
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). FFmpeg scales that frame
|
||||
/// to the selected output dimensions. The preview stays the editing view; this is the output
|
||||
/// view — see TASKS.md "Ship step 1 — Scene compositor".
|
||||
///
|
||||
/// Frame sources are supplied by a <see cref="Func{SceneElement, VideoFrame}"/> resolver
|
||||
/// (the caller maps webcam → DeviceId, images → AssetId, background → CaptureKey), keeping
|
||||
|
||||
@@ -14,8 +14,16 @@ public sealed class EncoderOptions
|
||||
public const string DefaultAudioPipeName = "ytllive_audio";
|
||||
|
||||
public string RtmpUrl { get; init; } = string.Empty;
|
||||
/// <summary>Final encoded output width and height.</summary>
|
||||
public int Width { get; init; } = 1920;
|
||||
public int Height { get; init; } = 1080;
|
||||
|
||||
/// <summary>Raw BGRA frame size from the compositor. Zero uses the final output size.</summary>
|
||||
public int InputWidth { get; init; }
|
||||
public int InputHeight { get; init; }
|
||||
|
||||
public int RawWidth => InputWidth > 0 ? InputWidth : Width;
|
||||
public int RawHeight => InputHeight > 0 ? InputHeight : Height;
|
||||
public int Fps { get; init; } = 60;
|
||||
public int BitrateKbps { get; init; } = 8000;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class FfmpegArgs
|
||||
"-hide_banner", "-loglevel", "info",
|
||||
"-stats", "-stats_period", "0.5",
|
||||
"-f", "rawvideo", "-pix_fmt", "bgra",
|
||||
"-video_size", $"{options.Width}x{options.Height}",
|
||||
"-video_size", $"{options.RawWidth}x{options.RawHeight}",
|
||||
"-framerate", options.Fps.ToString(),
|
||||
"-i", "pipe:0",
|
||||
"-f", "f32le", "-ar", options.AudioSampleRate.ToString(),
|
||||
@@ -53,6 +53,10 @@ public static class FfmpegArgs
|
||||
{
|
||||
var gop = options.GopSize;
|
||||
args.AddRange(["-map", "0:v", "-map", "1:a"]);
|
||||
if (options.RawWidth != options.Width || options.RawHeight != options.Height)
|
||||
{
|
||||
args.AddRange(["-vf", $"scale={options.Width}:{options.Height}:flags=bilinear,setsar=1"]);
|
||||
}
|
||||
args.AddRange(["-c:v", videoEncoder]);
|
||||
args.AddRange(["-b:v", $"{options.BitrateKbps}k", "-maxrate", $"{options.BitrateKbps}k",
|
||||
"-bufsize", $"{options.BitrateKbps * 2}k"]);
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ytLive.Services.Encoder;
|
||||
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
|
||||
/// managers + compositor and the encoder. While live it snapshots the active
|
||||
/// scene each tick, resolves every element to its latest frame, composites it
|
||||
/// into the tier's output frame, and paces frames into the encoder at the tier's
|
||||
/// into the tier's cropped input frame, and paces frames into the encoder at the tier's
|
||||
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
|
||||
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
|
||||
/// the capture managers and is fully hermetic in tests.
|
||||
@@ -722,11 +722,9 @@ public sealed class FramePump : IDisposable
|
||||
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver,
|
||||
CompositeTimings? timings = null)
|
||||
{
|
||||
// 1:1 guard: the cache stores the OUTPUT bytes and scratch is source-sized —
|
||||
// at 1:1 they are the same buffer size. Off-size tiers (a vertical 1080×1920
|
||||
// tier, an H264-1080p tier) fall back to a per-tick full render; the deployed
|
||||
// config is master-sized and caching a per-tick fresh scale buffer is the known
|
||||
// vertical-tier follow-up (see ai.md), not silently baked here.
|
||||
// The cache stores the compositor frame at crop size; FFmpeg scales that frame
|
||||
// to the final output dimensions. With product options, scratch and rendered
|
||||
// frames are therefore the same size, including for vertical output.
|
||||
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
|
||||
&& options.SourceRectHeight == options.OutputHeight;
|
||||
var signature = useCache
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `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 |
|
||||
| `Compositor/AlertTickerRenderer.cs` | **Shared ticker renderer (TASK 47):** marquee frame rendered per call by pure byte-math; the text pill is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha). Supports AlertBox active-event tickers and ordinary scene-local ticker sources, with per-source `Ticker` settings. **Dynamic output — never baked** (unlike the social bar) |
|
||||
| `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/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), raw input W×H from the compositor crop, final output 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` |
|
||||
| `Encoder/IEncoderProcess.cs` | Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches `Process` directly |
|
||||
|
||||
@@ -422,6 +422,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
var url = _rtmpUrlProvider();
|
||||
var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
||||
var compositor = BuildCompositorOptions();
|
||||
return new EncoderOptions
|
||||
{
|
||||
RtmpUrl = url ?? string.Empty,
|
||||
@@ -430,6 +431,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
RecordPath = _activeRecordPath,
|
||||
Width = quality.Width,
|
||||
Height = quality.Height,
|
||||
InputWidth = compositor.OutputWidth,
|
||||
InputHeight = compositor.OutputHeight,
|
||||
Fps = quality.Fps,
|
||||
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
||||
};
|
||||
|
||||
@@ -654,19 +654,21 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
|
||||
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
|
||||
// FFmpeg, not the compositor, scales this crop to the tier's final dimensions.
|
||||
// internal = test seam (the zoom layer must NOT change the output crop — the
|
||||
// box is a composited layer, never the crop source).
|
||||
internal CompositorOptions BuildCompositorOptions()
|
||||
{
|
||||
var quality = SelectedQuality;
|
||||
var sourceRectWidth = (int)Math.Round(OutputRectWidth);
|
||||
var sourceRectHeight = (int)Math.Round(OutputRectHeight);
|
||||
return new CompositorOptions
|
||||
{
|
||||
SourceRectX = (int)Math.Round(OutputRectX),
|
||||
SourceRectY = (int)Math.Round(OutputRectY),
|
||||
SourceRectWidth = (int)Math.Round(OutputRectWidth),
|
||||
SourceRectHeight = (int)Math.Round(OutputRectHeight),
|
||||
OutputWidth = quality.Width,
|
||||
OutputHeight = quality.Height,
|
||||
SourceRectWidth = sourceRectWidth,
|
||||
SourceRectHeight = sourceRectHeight,
|
||||
OutputWidth = sourceRectWidth,
|
||||
OutputHeight = sourceRectHeight,
|
||||
};
|
||||
} // The credit's cadence, timing, placement and licence gate all live in
|
||||
// BrandFlashPresenter; MainViewModel only routes it to the frame pump and the
|
||||
|
||||
@@ -254,9 +254,10 @@ stays XAML (editing view); the compositor is the output view.
|
||||
`VisualBrush` samples all of `CanvasGrid`, and if the dims were inside it the box would show the
|
||||
dim shading — especially after the box is dragged away and the fixed capture slides under the dims.
|
||||
|
||||
- **Render the active tier's output rect directly** (`CompositorOptions {SourceRectX/Y/W/H,
|
||||
OutputWidth, OutputHeight}`, fed from `MainViewModel.OutputRect*`): 16:9 = full 1920×1080 1:1; the
|
||||
vertical 9:16 tier = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920.
|
||||
- **Composite the active tier's crop at crop size** (`CompositorOptions {SourceRectX/Y/W/H,
|
||||
OutputWidth, OutputHeight}`, fed from `MainViewModel.OutputRect*`): 16:9 = full 1920×1080; the
|
||||
vertical 9:16 tier = centered 608×1080 crop. FFmpeg's `scale` filter resizes the raw BGRA frame
|
||||
to the selected output tier after it leaves the frame pump; `setsar=1` keeps square pixels.
|
||||
- **CPU posture:** with FFmpeg as a subprocess the master crosses a CPU readback to the pipe every frame
|
||||
anyway, so GPU compositing buys little at this layer count (2-3 live layers; static layers
|
||||
pre-composite once into a cached base). GPU effort belongs to **NVENC** (the encoder), not composition;
|
||||
@@ -363,9 +364,11 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
|
||||
pin every frame's content, repeated backing-array identity proves the pool recycles),
|
||||
`Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch` (memcpy branch + scratch
|
||||
sentinel). `FakeEncoder.SubmitFrameAsync` now snapshots bytes like the real stdin write — holding the
|
||||
reference would race legitimate recycling. Take 5 must show `avg render ≤ ~10ms, ≈300/300`; the
|
||||
vertical tier's final 1080×1920 `BilinearScale` still allocates fresh per frame (same GC lesson when
|
||||
someone streams vertical — recorded as a follow-up, not silently "done").
|
||||
reference would race legitimate recycling. Take 5 must show `avg render ≤ ~10ms, ≈300/300`.
|
||||
**Vertical/output scaling moved to bundled FFmpeg (2026-11-04):** the C# compositor no longer
|
||||
enlarges the 608×1080 crop to 1080×1920 on every frame. The bundled FFmpeg `scale` filter processed
|
||||
300 synthetic BGRA frames in 0.447s on this machine; that is a scaler-only microbenchmark, not a
|
||||
live-stream result.
|
||||
- **Slice 3 — chat raster-on-change (2026-09-04, take 5):** take 5 measured `138/300, avg render
|
||||
25.5ms` — the per-tick blits were cheap now, but `ResolveOutputFrame` → `RenderChatBox` ran a FULL
|
||||
WPF raster (`ChatBoxRenderer`: FormattedText + `RenderTargetBitmap` + CopyPixels + channel swap)
|
||||
|
||||
+8
-4
@@ -67,7 +67,8 @@ a machine prerequisite (a manifest framework-dependency + graceful-failure path,
|
||||
### Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
|
||||
|
||||
The encoder is a **thin orchestrator over `ffmpeg.exe`** — no H.264/AAC code in the app. It spawns the
|
||||
subprocess (path from `IFfmpegLocator`), feeds raw BGRA master frames into stdin, and parses `-stats`
|
||||
subprocess (path from `IFfmpegLocator`), feeds raw BGRA compositor-crop frames into stdin, lets FFmpeg
|
||||
scale them to the selected output tier, and parses `-stats`
|
||||
stderr lines into `StreamHealth` (bitrate/FPS/duration, dropped-from-frame-count). `FfmpegEncoder`
|
||||
(`IFfmpegEncoder` seam) holds: `StartAsync` (locate → probe `-encoders` → spawn → stderr loop →
|
||||
drain loop), `SubmitFrameAsync` (slice 10: bounded-queue ENQUEUE — never a pipe write; the drain task
|
||||
@@ -80,8 +81,10 @@ integration test fakes the whole subprocess (probe + encoder) with a Channel-bac
|
||||
`Complete()` is EOF (`null`), never a `ChannelClosedException`.
|
||||
|
||||
**Decisions (locked):** args are pure (`FfmpegArgs.Build`, no string building in the encoder):
|
||||
`-f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` (**NO `-re`** — the FramePump
|
||||
is the pacer since slice 9, 2026-09-10; `-re` added a second, fighting clock on the rawvideo demux)
|
||||
`-f rawvideo -pix_fmt bgra -video_size inputWxH -framerate FPS -i pipe:0` (**NO `-re`** — the FramePump
|
||||
is the pacer since slice 9, 2026-09-10; `-re` added a second, fighting clock on the rawvideo demux).
|
||||
When input and output dimensions differ, each output block adds `-vf scale=outputW:outputH:flags=bilinear,setsar=1`;
|
||||
otherwise frames pass through at their existing dimensions.
|
||||
+ a **real audio input — the
|
||||
mixer writes IEEE-float stereo to a Windows named pipe** (`-f f32le -ar 48000 -ac 2
|
||||
-i \\.\pipe\ytllive_audio`, name via `EncoderOptions.AudioPipeName`; replaced the old `-f lavfi -i
|
||||
@@ -99,7 +102,8 @@ other — while the dual-block machinery stays generic.**
|
||||
`FfmpegEncoder.StartAsync` throws unless at least one output is enabled.
|
||||
**Encoder choice is probed from the binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure):
|
||||
NVENC → QSV → AMF → OpenH264 fallback, **never libx264** (GPL; see Licensing). `EncoderOptions.VideoEncoder`
|
||||
forces one and skips the probe. `EncoderOptions` also carries W×H/FPS/bitrate from the quality tier and
|
||||
forces one and skips the probe. `EncoderOptions` carries raw input W×H from the compositor crop, final
|
||||
output W×H/FPS/bitrate from the quality tier, and
|
||||
`GopSize` = FPS×4. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`),
|
||||
driven by the `FramePump` below.
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The output compositor (TASK 4 ship step 1): renders a scene into the encoder's
|
||||
/// master frame, mirroring the XAML preview minus the editing chrome. The integration
|
||||
/// raw input frame, mirroring the XAML preview minus the editing chrome. The integration
|
||||
/// test composites a full scene (background + round webcam + images + mirror + border)
|
||||
/// and asserts per-layer probe pixels; the vertical-tier test asserts the 9:16 crop
|
||||
/// and upscale. Frames are pushed by the test — no capture managers involved.
|
||||
/// and asserts per-layer probe pixels; the vertical-tier test checks the crop frame and
|
||||
/// FFmpeg's final 9:16 resize. Frames are pushed by the test — no capture managers involved.
|
||||
/// </summary>
|
||||
public class SceneCompositorTests
|
||||
{
|
||||
@@ -407,9 +408,18 @@ public class SceneCompositorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
|
||||
public void Render_VerticalTier_Crops_And_Configures_Ffmpeg_1080x1920_Scale()
|
||||
{
|
||||
var red = Solid(1920, 1080, 255, 0, 0);
|
||||
var backgroundPixels = new byte[1920 * 1080 * 4];
|
||||
for (var y = 0; y < 1080; y++)
|
||||
for (var x = 0; x < 1920; x++)
|
||||
{
|
||||
var i = (y * 1920 + x) * 4;
|
||||
backgroundPixels[i] = (byte)(x * 255 / 1919);
|
||||
backgroundPixels[i + 1] = (byte)(y * 255 / 1079);
|
||||
backgroundPixels[i + 3] = 255;
|
||||
}
|
||||
var backgroundFrame = new VideoFrame(1920, 1080, backgroundPixels);
|
||||
var green = Solid(1280, 720, 0, 255, 0);
|
||||
|
||||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||||
@@ -421,26 +431,40 @@ public class SceneCompositorTests
|
||||
VideoFrame? FrameFor(SceneElement e) => e switch
|
||||
{
|
||||
WebcamSceneConfig => green,
|
||||
Source { IsBackground: true } => red,
|
||||
Source { IsBackground: true } => backgroundFrame,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectX = 656, SourceRectY = 0, SourceRectWidth = 607, SourceRectHeight = 1080,
|
||||
OutputWidth = 1080, OutputHeight = 1920,
|
||||
SourceRectX = 656, SourceRectY = 0, SourceRectWidth = 608, SourceRectHeight = 1080,
|
||||
OutputWidth = 608, OutputHeight = 1080,
|
||||
};
|
||||
|
||||
var output = new SceneCompositor().Render(scene, FrameFor, null, options);
|
||||
|
||||
Assert.Equal(1080, output.Width);
|
||||
Assert.Equal(1920, output.Height);
|
||||
// top-left of the crop is pure background (webcam starts at crop y=100, inset from the corner)
|
||||
AssertColor(output, 0, 0, 255, 0, 0);
|
||||
// the webcam center (crop 150,250) scales to output ~(267,444) and stays green
|
||||
AssertColor(output, 267, 444, 0, 255, 0);
|
||||
Assert.Equal(608, output.Width);
|
||||
Assert.Equal(1080, output.Height);
|
||||
// The centered crop and webcam are composed at source size.
|
||||
AssertColor(output, 0, 0, 0, 0, 87);
|
||||
AssertColor(output, 150, 250, 0, 255, 0);
|
||||
// far from the webcam, still background
|
||||
AssertColor(output, 978, 1688, 255, 0, 0);
|
||||
AssertColor(output, 550, 950, 0, 224, 160);
|
||||
|
||||
// FFmpeg receives the crop, then scales it to the tier's encoded dimensions.
|
||||
var args = FfmpegArgs.Build(new EncoderOptions
|
||||
{
|
||||
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
|
||||
InputWidth = output.Width,
|
||||
InputHeight = output.Height,
|
||||
Width = 1080,
|
||||
Height = 1920,
|
||||
Fps = 60,
|
||||
}, "libopenh264").ToArray();
|
||||
var inputSize = Array.IndexOf(args, "-video_size");
|
||||
var scaleFilter = Array.IndexOf(args, "-vf");
|
||||
Assert.Equal("608x1080", args[inputSize + 1]);
|
||||
Assert.Equal("scale=1080:1920:flags=bilinear,setsar=1", args[scaleFilter + 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -616,4 +640,3 @@ public class StretchMathTests
|
||||
Assert.Equal(255, scaled.BgraPixels[i + 1]); // solid green survives the scale
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user