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:
2026-11-04 10:52:35 -08:00
parent 76e8c22815
commit 6310f50c27
11 changed files with 93 additions and 47 deletions
+5 -5
View File
@@ -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
{
+4 -3
View File
@@ -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
+8
View File
@@ -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;
+5 -1
View File
@@ -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"]);
+4 -6
View File
@@ -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
View File
@@ -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),
};
+7 -5
View File
@@ -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
+9 -6
View File
@@ -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
View File
@@ -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.
+39 -16
View File
@@ -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
}
}