TASK 4 ship step 3: encoder + RTMP push — FFmpeg subprocess with probed H.264 picker, BGRA stdin feed, stderr health parsing, graceful stop — 122 tests passing, 0 warnings

This commit is contained in:
2026-08-12 20:32:24 -07:00
parent e494dce311
commit ba427e85e1
13 changed files with 891 additions and 62 deletions
+31 -60
View File
@@ -7,69 +7,40 @@
## Session state (last updated: 2026-08-12) ## Session state (last updated: 2026-08-12)
- **Branch:** `main` — the social bar v2 push is **committed + pushed** (`9873eed`, - **Branch:** `main`. TASK 4 ship step 3 (encoder + RTMP push) is **built and
18 files, +1878/321, 112 tests passing, 0 warnings). Working tree is **dirty tested** but **NOT committed** — the working tree is dirty with the encoder
with the post-push docs backfill only** (ai.md, README.md, TASKS.md schema (10 new files in `Services/Encoder/` + `ytLive.Tests/FfmpegEncoderTests.cs`) and
section, THIRD-PARTY-NOTICES.txt) — ready to commit when the user says so. its memory updates (TASKS.md, ai.md, Services/index.md). Ready to commit when
- **Finished this session (round 3):** user's live-test report said the mastodon the user says so.
icon still didn't show for `@gramps@llamachile.tube`. Root cause: the identity - **Finished this session:** TASK 4 ship step 3 — the FFmpeg subprocess encoder:
domain `llamachile.tube` is YunoHost-SSO-gated — `/.well-known/nodeinfo`, `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/
`/@gramps`, and webfinger all answer with the SSO login page, so nodeinfo `FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/
returned nothing and the icon fell back to the honeycomb glyph. The real `FfmpegEncoderPicker` in `Services/Encoder/`. StartAsync (locate → probe
instance lives at `mastodon.llamachile.tube`, and only the bare root `-encoders` → spawn → stderr loop), SubmitFrameAsync (serialized BGRA stdin),
`https://llamachile.tube/` 302s to it ("default app" redirect). Fix: StopAsync (stdin EOF → ffmpeg finalizes; 10s kill watchdog), ProcessFailed on
`HttpSocialValidator.TryFetchFediverseSoftwareAsync` now, when nodeinfo on the unexpected non-zero exit. Not yet constructed by the app (ship step 5 wiring).
identity domain fails, follows the root redirect (`ResolveInstanceHostAsync`, Build: **0 warnings**. Tests: **122 passing** (was 112; +10 new).
reads `resp.RequestMessage.RequestUri.Host`) and re-runs the nodeinfo lookup
on the resolved host. New test `FediverseHandle_RootRedirectToSubdomain_ResolvesSoftware`.
Build: **0 warnings**. Tests: **112 passing**.
- **What landed (all rounds this session):**
1. **Fediverse icon resolution**`DetectService` maps `@user@domain` to the
new `SocialService.Fediverse` (was `Link`/chain icon). On validate,
`HttpSocialValidator` best-effort GETs `https://{domain}/.well-known/nodeinfo`,
follows the first nodeinfo `links[].href`, reads `software.name`, and returns
it in `SocialLookupResult.FediverseSoftware`. `SocialEntry`/`SocialSlotViewModel`
carry `FediverseSoftware`; `SocialServiceIcons.LogoDataForFediverse(software)`
maps it to a bundled logo (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/
firefish — Simple Icons CC0), falling back to the `FediverseIconData`
honeycomb glyph for unknown software (GoToSocial/Sharkey/Akkoma aren't in
Simple Icons). Nodeinfo failure still validates — the glyph falls back.
2. **Redirect resolution (round 3)** — identity domains that 302 their root
to the real instance (YunoHost default-app subdomains) resolve software via
the root redirect when identity-domain nodeinfo is SSO-blocked.
3. **Persistence** — new `SocialEntry.Software TEXT` column via
`MigrateSocialEntryTable()` (column-presence pattern, same as the others);
saved/loaded alongside Service/Handle/ProfileUrl.
4. **Icon colors**`IconButton` style gains `Foreground="#d0d0d0"`
(`Themes/Controls.xaml`); the dialog's trash button overrides
`Foreground="#e94560"` (`SocialsDialog.xaml`). All other `IconButton`
usages are `Path` content with explicit `Fill`, so unaffected.
- **Landmines:** - **Landmines:**
- `LayoutStore.Socials` is only populated by `Load()` — tests must call - `ChannelReader.ReadAsync` on a completed channel **throws**
`store.Load()` before asserting it. `ChannelClosedException` — it does NOT return `null` like a StreamReader EOF.
- `DetectService("justaname")`**Website** with empty handle; only The test fake (`QueuedReader` in `FfmpegEncoderTests.cs`) catches it and
unparseable input or `@user@domain` yields Link/Fediverse. returns `null`, or the encoder's stderr loop treats it as a fault and
- Dialog sign-in provider is `Func<Task<YouTubeChannel?>>`; test fakes must `ProcessFailed` never fires (that's exactly what happened on the first run —
return `Task.FromResult` (sync-completed) so the fire-and-forget command see commit history).
settles before the next assert. - The probe process (`FakeEncoderProcess`) must be a *separate* `IEncoderProcess`
- `SocialBarBottomTop = 1040` literal lives in `MainWindow.xaml.cs` (the VM's instance from the encoder process in `Start_*` tests — `StartAsync` calls the
`MasterFrameHeight` is private). factory twice (probe → encoder), and the fake can't simulate both roles at
- `Cancel_AbortsInFlightValidation_WithoutMutatingSlot` relies on the once.
BlockingValidator's gate completing synchronously (no - `StopAsync` waits the full `ExitTimeout` if the fake's process doesn't signal
`RunContinuationsAsynchronously`) — the assertions run after exit — fakes must call `SignalExit()` inside `StopAsync`'s stdin-EOF path.
`Gate.TrySetResult` returns because the awaited continuation executes inline. - Windows-only: `FfmpegEncoderProcess` sets `UseShellExecute=false` +
- Nodeinfo cancellation test cancels mid-lookup via a counting stub; the `RedirectStandardXxx=true` — never spawn with a shell.
post-fetch `ct.IsCancellationRequested` check is what reports `Canceled`. - **Next step:** TASK 4 ship step 4 — WASAPI audio capture (loopback + mic)
- Round-3 redirect fallback fires only when identity-domain nodeinfo fails; feeding `AudioLevel` (req 7). Nothing else queued — do not expand the task
the root follow happens automatically (HttpClient default auto-redirect), queue on your own.
and `RequestMessage.RequestUri.Host` is read from the final response.
- **Next step:** TASK 4 ship step 3 — the encoder + RTMP push (FFmpeg subprocess:
frames via stdin, stderr health parsing, FLV mux + push to the cached reusable
stream's ingestion URL). Nothing else queued — do not expand the task queue on
your own.
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`; - **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`); OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`);
layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v8; the new `SocialEntry.Software` layout DB `%APPDATA%\ytLlive\ytLlive.db` (schema v8; `SocialEntry.Software`
column is a column-presence migration like the others, no version bump); OAuth callback column is a column-presence migration like the others, no version bump); OAuth callback
`http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`. `http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
+27
View File
@@ -0,0 +1,27 @@
namespace ytLive.Services.Encoder;
/// <summary>
/// Everything the FFmpeg subprocess encoder needs for one go-live (TASK 4 ship
/// step 3). <see cref="RtmpUrl"/> is the FULL ingestion URL — the reusable
/// stream's ingest address plus its stream key (<c>rtmp://a.rtmp.youtube.com/live2/&lt;key&gt;</c>).
/// Resolution/FPS/bitrate come from the active quality tier; audio is a silent
/// placeholder track until the WASAPI capture step replaces the input.
/// </summary>
public sealed class EncoderOptions
{
public string RtmpUrl { get; init; } = string.Empty;
public int Width { get; init; } = 1920;
public int Height { get; init; } = 1080;
public int Fps { get; init; } = 60;
public int BitrateKbps { get; init; } = 8000;
/// <summary>H.264 encoder name for <c>-c:v</c>; the encoder probes and prefers
/// nvenc → qsv → amf → libopenh264 when not forced.</summary>
public string? VideoEncoder { get; init; }
public int AudioSampleRate { get; init; } = 48000;
public int AudioChannels { get; init; } = 2;
/// <summary>GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound.</summary>
public int GopSize => Fps * 4;
}
+46
View File
@@ -0,0 +1,46 @@
namespace ytLive.Services.Encoder;
/// <summary>
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
/// raw BGRA frames via stdin (paced <c>-re</c>), silent placeholder audio via lavfi
/// <c>anullsrc</c> (the WASAPI step replaces this input), H.264 + AAC encoding, FLV
/// muxing to the ingestion URL. Pure — the encoder just starts
/// <c>ffmpeg.exe [Build(...)]</c>.
/// </summary>
public static class FfmpegArgs
{
public static IReadOnlyList<string> Build(EncoderOptions options, string videoEncoder)
{
var gop = options.GopSize;
return
[
"-hide_banner",
"-loglevel", "info",
"-stats",
"-stats_period", "0.5",
"-re",
"-f", "rawvideo",
"-pix_fmt", "bgra",
"-video_size", $"{options.Width}x{options.Height}",
"-framerate", options.Fps.ToString(),
"-i", "pipe:0",
"-f", "lavfi",
"-i", $"anullsrc=channel_layout=stereo:sample_rate={options.AudioSampleRate}",
"-c:v", videoEncoder,
"-b:v", $"{options.BitrateKbps}k",
"-maxrate", $"{options.BitrateKbps}k",
"-bufsize", $"{options.BitrateKbps * 2}k",
"-g", gop.ToString(),
"-keyint_min", gop.ToString(),
"-sc_threshold", "0",
"-bf", "0",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "128k",
"-ar", options.AudioSampleRate.ToString(),
"-ac", options.AudioChannels.ToString(),
"-f", "flv",
options.RtmpUrl,
];
}
}
+263
View File
@@ -0,0 +1,263 @@
using System.Diagnostics;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services.Encoder;
/// <summary>
/// The default <see cref="IFfmpegEncoder"/>: spawns <c>ffmpeg.exe</c> (resolved via
/// <see cref="IFfmpegLocator"/>), feeds raw BGRA frames into stdin, and parses the
/// <c>-stats</c> progress lines into <see cref="StreamHealth"/>. Encoder choice is
/// probed from the binary's <c>-encoders</c> listing (hardware NVENC/QSV/AMF first,
/// OpenH264 software fallback — never libx264, see the license posture) unless
/// <see cref="EncoderOptions.VideoEncoder"/> forces one.
///
/// Graceful stop = close stdin (EOF) → ffmpeg finalizes the FLV and exits by itself;
/// a watchdogs kill fires only if it hasn't exited shortly after EOF.
/// </summary>
public sealed class FfmpegEncoder : IFfmpegEncoder
{
public event EventHandler<StreamHealth>? HealthUpdated;
public event EventHandler<string>? ProcessFailed;
private readonly IFfmpegLocator _locator;
private readonly Func<IEncoderProcess> _processFactory;
private readonly object _gate = new();
private IEncoderProcess? _process;
private EncoderOptions? _options;
private StreamHealth _health = new() { Status = StreamStatus.Offline };
private Task? _stderrLoop;
private bool _stopRequested;
public FfmpegEncoder(
IFfmpegLocator locator,
Func<IEncoderProcess>? processFactory = null)
{
_locator = locator;
_processFactory = processFactory ?? (() => new FfmpegEncoderProcess());
}
public bool IsRunning { get; private set; }
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
{
if (options == null) throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(options.RtmpUrl))
throw new ArgumentException("An RTMP ingestion URL is required.", nameof(options));
lock (_gate)
{
if (IsRunning) throw new InvalidOperationException("The encoder is already running.");
_options = options;
}
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
var args = FfmpegArgs.Build(options, encoder);
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var arg in args) startInfo.ArgumentList.Add(arg);
IEncoderProcess process;
try
{
process = _processFactory();
process.Start(startInfo);
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
lock (_gate) _options = null;
throw;
}
lock (_gate)
{
_process = process;
IsRunning = true;
_health = new StreamHealth { Status = StreamStatus.Streaming };
}
_stopRequested = false;
_stderrLoop = RunStderrLoopAsync(process);
}
/// <summary>
/// Write one raw BGRA frame to ffmpeg's stdin. Serialized internally; callers
/// (the compositor pump) may race freely. Frames are written as-is — the caller
/// paces to capture rate (the compositor's job, ship step 5).
/// </summary>
public async Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
{
if (frame == null) throw new ArgumentNullException(nameof(frame));
IEncoderProcess? process;
lock (_gate)
{
if (!IsRunning) throw new InvalidOperationException("The encoder is not running.");
process = _process;
}
var bytes = frame.BgraPixels;
await process!.StandardInput.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
}
public async Task StopAsync(CancellationToken cancellationToken = default)
{
IEncoderProcess? process;
Task? loop;
lock (_gate)
{
if (!IsRunning) return;
_stopRequested = true;
process = _process;
loop = _stderrLoop;
}
try
{
process!.StandardInput.Dispose(); // EOF → ffmpeg finalizes + exits
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: closing stdin failed");
}
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
process!.Kill();
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
process!.Kill();
}
try
{
if (loop != null) await loop.ConfigureAwait(false);
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
}
process.Dispose();
lock (_gate)
{
IsRunning = false;
_health.Status = StreamStatus.Offline;
_health.LastError = null;
_process = null;
_options = null;
}
AppLog.Write($"FFmpeg encoder stopped (exit {process.ExitCode})");
}
public void Dispose()
{
lock (_gate)
{
if (!IsRunning) return;
_process?.Kill();
_process?.Dispose();
_process = null;
IsRunning = false;
}
}
private async Task<string> ProbeEncoderAsync(string ffmpegPath, CancellationToken cancellationToken)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-encoders");
using var probe = _processFactory();
probe.Start(startInfo);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(15));
var output = await probe.StandardOutput.ReadToEndAsync(cts.Token).ConfigureAwait(false);
return FfmpegEncoderPicker.Pick(output);
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
return FfmpegEncoderPicker.Preference[^1];
}
}
private Task RunStderrLoopAsync(IEncoderProcess process)
{
return Task.Run(async () =>
{
try
{
while (true)
{
var line = await process.StandardError.ReadLineAsync().ConfigureAwait(false);
if (line == null) break;
OnStderrLine(process, line);
}
var code = process.ExitCode;
var stillRunning = false;
lock (_gate) stillRunning = IsRunning;
if (stillRunning && !_stopRequested && code != 0)
{
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly ({code})");
_health.LastError = $"FFmpeg exited with code {code}";
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {code}");
}
}
catch (Exception ex)
{
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
}
});
}
private void OnStderrLine(IEncoderProcess process, string line)
{
var progress = FfmpegProgressParser.TryParse(line);
if (progress == null) return;
var dropped = Math.Max(0, (long)Math.Round(progress.Value.Fps * progress.Value.Duration.TotalSeconds) - progress.Value.Frame);
lock (_gate)
{
_health.CurrentBitrate = progress.Value.BitrateKbps;
_health.FPS = progress.Value.Fps;
_health.DroppedFrames = (int)dropped;
_health.StreamDuration = progress.Value.Duration;
}
HealthUpdated?.Invoke(this, _health);
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace ytLive.Services.Encoder;
/// <summary>
/// Picks the best available H.264 encoder from ffmpeg's <c>-encoders</c> listing,
/// honoring the license posture (no GPL libx264): hardware NVENC → QSV → AMF, then
/// the OpenH264 software fallback. Pure parser — the <c>-encoders</c> probe output is
/// fetched by the encoder via an <see cref="IEncoderProcess"/> and fed here.
/// </summary>
public static class FfmpegEncoderPicker
{
/// <summary>Preference order, best first. All ship in the pinned BtbN lgpl-shared build.</summary>
public static readonly string[] Preference =
[
"h264_nvenc",
"h264_qsv",
"h264_amf",
"libopenh264",
];
/// <summary>
/// First <see cref="Preference"/> entry present in the probe output, or the
/// software fallback (which the pinned build always contains) if none matched.
/// Never returns libx264 — it is GPL and would contaminate the paid product.
/// </summary>
public static string Pick(string probeOutput)
{
var available = probeOutput.Split('\n');
foreach (var name in Preference)
{
if (available.Any(line => line.Contains(name, StringComparison.Ordinal)))
return name;
}
return Preference[^1];
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Diagnostics;
using System.IO;
namespace ytLive.Services.Encoder;
/// <summary>
/// The real <see cref="IEncoderProcess"/>: a <see cref="Process"/> with all three
/// std streams redirected. Constructed by <see cref="FfmpegEncoder"/> for both the
/// encoder subprocess and the <c>-encoders</c> probe.
/// </summary>
public sealed class FfmpegEncoderProcess : IEncoderProcess
{
private readonly Process _process;
public FfmpegEncoderProcess() => _process = new Process { EnableRaisingEvents = true };
public void Start(ProcessStartInfo startInfo)
{
_process.StartInfo = startInfo;
_process.Start();
}
public Stream StandardInput => _process.StandardInput.BaseStream;
public TextReader StandardOutput => _process.StandardOutput;
public TextReader StandardError => _process.StandardError;
public bool HasExited => _process.HasExited;
public int ExitCode => _process.ExitCode;
public void Kill()
{
if (!_process.HasExited) _process.Kill();
}
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
=> _process.WaitForExitAsync(cancellationToken);
public void Dispose() => _process.Dispose();
}
+47
View File
@@ -0,0 +1,47 @@
using System.Text.RegularExpressions;
namespace ytLive.Services.Encoder;
/// <summary>A decoded ffmpeg <c>-stats</c> progress line (pure data).</summary>
public readonly record struct FfmpegProgress(
long Frame,
double Fps,
double BitrateKbps,
TimeSpan Duration,
long SizeBytes);
/// <summary>
/// Pure parser for ffmpeg's periodic <c>frame= fps= size= time= bitrate=</c> stderr
/// lines (the <c>-stats</c>/<c>-stats_period</c> output). Unit-tested in isolation
/// so the encoder loop stays a thin wire.
/// </summary>
public static class FfmpegProgressParser
{
// frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x
private static readonly Regex Line = new(
@"frame=\s*(?<frame>\d+)\s+fps=\s*(?<fps>[\d.]+).*?"
+ @"size=\s*(?<size>\d+)KiB.*?"
+ @"time=(?<time>\d{2}):(?<min>\d{2}):(?<sec>[\d.]+).*?"
+ @"bitrate=\s*(?<bitrate>[\d.]+)kbits/s",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>Returns null for non-progress lines (errors, warnings, banner).</summary>
public static FfmpegProgress? TryParse(string line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
var m = Line.Match(line);
if (!m.Success) return null;
var frame = long.Parse(m.Groups["frame"].Value);
var fps = double.Parse(m.Groups["fps"].Value, System.Globalization.CultureInfo.InvariantCulture);
var size = long.Parse(m.Groups["size"].Value);
var bitrate = double.Parse(m.Groups["bitrate"].Value, System.Globalization.CultureInfo.InvariantCulture);
var hours = int.Parse(m.Groups["time"].Value);
var minutes = int.Parse(m.Groups["min"].Value);
var seconds = double.Parse(m.Groups["sec"].Value, System.Globalization.CultureInfo.InvariantCulture);
var duration = TimeSpan.FromSeconds(hours * 3600 + minutes * 60 + seconds);
return new FfmpegProgress(frame, fps, bitrate, duration, size * 1024);
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Diagnostics;
using System.IO;
namespace ytLive.Services.Encoder;
/// <summary>
/// Seam around a spawned subprocess (the encoder's ffmpeg and the encoder probe).
/// Exposes the redirected stdin (binary frames), stdout (probe listing) and stderr
/// (progress lines) plus exit control, so the encoder logic never touches
/// <c>System.Diagnostics.Process</c> directly and tests can fake the whole thing.
/// </summary>
public interface IEncoderProcess : IDisposable
{
void Start(ProcessStartInfo startInfo);
/// <summary>Raw binary stdin — the encoder writes BGRA frames here.</summary>
Stream StandardInput { get; }
TextReader StandardOutput { get; }
TextReader StandardError { get; }
bool HasExited { get; }
int ExitCode { get; }
void Kill();
Task WaitForExitAsync(CancellationToken cancellationToken = default);
}
+25
View File
@@ -0,0 +1,25 @@
using ytLive.Models;
namespace ytLive.Services.Encoder;
/// <summary>
/// The live encoder seam (TASK 4 ship step 3): start an FFmpeg subprocess that
/// encodes raw BGRA frames from stdin and pushes FLV to an RTMP ingestion URL,
/// raising parsed health stats from stderr. The frame producer (compositor →
/// capture managers, TASK 4 ship step 5) feeds <see cref="SubmitFrameAsync"/> at
/// capture rate; this service serializes writes, parses progress, and tears the
/// process down gracefully. Constructor-injected <see cref="IFfmpegLocator"/> +
/// process factory keep it hermetic (tests fake both).
/// </summary>
public interface IFfmpegEncoder : IDisposable
{
/// <summary>Fires on each parsed ffmpeg <c>-stats</c> progress line (~2 Hz).</summary>
event EventHandler<StreamHealth>? HealthUpdated;
/// <summary>Fires when the subprocess dies unexpectedly (non-zero exit while live).</summary>
event EventHandler<string>? ProcessFailed;
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
Task StopAsync(CancellationToken cancellationToken = default);
}
+8
View File
@@ -33,6 +33,14 @@ External-facing logic: YouTube API, persistence. See
| `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`) | | `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 | | `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 instead. Constructor takes optional `HttpClient` for tests |
| `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. Not yet constructed by the app (ship step 5 wires it) |
| `Encoder/IEncoderProcess.cs` | Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches `Process` directly |
| `Encoder/FfmpegEncoderProcess.cs` | The real process wrapper (`Process` with all three std streams redirected) |
| `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/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. Not yet constructed by the app (the encoder step wires it) | | `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. Not yet constructed by the app (the encoder step wires it) |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
+30 -1
View File
@@ -199,7 +199,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
1.**Ship step 1 — the output compositor SHIPPED** (2026-08-10) 1.**Ship step 1 — the output compositor SHIPPED** (2026-08-10)
2.**Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10) 2.**Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10)
3. **Encoder + RTMP push** — the FFmpeg subprocess: frames via stdin, stderr health parsing, FLV mux + push to the cached reusable stream's ingestion URL 3. **Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below)
4.**WASAPI audio capture** — loopback (desktop/game) + the picked mic feeding `AudioLevel` so the realtime meter comes alive (req 7) 4.**WASAPI audio capture** — loopback (desktop/game) + the picked mic feeding `AudioLevel` so the realtime meter comes alive (req 7)
5.**Frame-pipeline wiring**`CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder 5.**Frame-pipeline wiring**`CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder
6.**Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (req 5) 6.**Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (req 5)
@@ -361,6 +361,35 @@ Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → do
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**. cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
#### Ship step 3 — Encoder + RTMP push (the FFmpeg subprocess)
**Goal:** encode raw BGRA master frames into H.264+AAC FLV and push them to the reusable stream's RTMP
ingestion URL — one battle-tested subprocess doing encode + mux + push + reconnect, the app feeding
frames via stdin and parsing stderr for health (req 2).
**Decisions (locked):** the encoder is a thin orchestrator over `ffmpeg.exe` — no H.264/AAC code in the
app. Arguments (pure `FfmpegArgs.Build`): `-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS
-i pipe:0` (frames in), a **silent placeholder audio track** via `-f lavfi -i anullsrc` (the WASAPI
capture step replaces this input), `-c:v <encoder> -b:v K -maxrate K -bufsize 2K` + **`-g fps×4`
`-keyint_min fps×4` `-sc_threshold 0` `-bf 0` `-pix_fmt yuv420p`** (the keyframe ≤4s / closed-GOP /
H.264 compliance), `-c:a aac -ar 48000 -ac 2`, `-f flv <rtmpUrl>`. Encoder choice is **probed from the
binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure): hardware NVENC → QSV → AMF, then OpenH264
software fallback — **never libx264** (GPL; see `ai.md` → Licensing). The seam (`IFfmpegEncoder` +
`IEncoderProcess`, constructor-injected locator + process factory) keeps it hermetic — tests fake the
whole subprocess (probe + encoder), no real binary.
**Behavior:** `StartAsync` (locate → probe → spawn → stderr loop), `SubmitFrameAsync` (serialized BGRA
stdin writes, ~2 Hz health via `HealthUpdated`/`StreamHealth` — bitrate/FPS/duration/dropped-from-frame-
count), `StopAsync` (stdin EOF → ffmpeg finalizes + exits by itself; 10s watchdog kill), `ProcessFailed`
on a non-zero unexpected exit.
**Built (2026-08-12):** `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/
`FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` in
`Services/Encoder/`. Not yet constructed by the app (the frame-pipeline wiring, ship step 5, owns it).
Tests: `FfmpegEncoderTests` integration (probe → spawn with NVENC preferred → frames into stdin →
progress parsed → graceful stop, no kill) + units (args compliance/GOP, progress parser, picker
preference + GPL guard, no-URL/not-running/noop stops, process-death `ProcessFailed`) — **122 passing**.
--- ---
## TASK 5 — YouTube Live Stream Management ## TASK 5 — YouTube Live Stream Management
+25 -1
View File
@@ -114,7 +114,7 @@ C# / WPF (.NET 8) following MVVM:
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`) - `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending) - Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream - `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED** — full plan in `TASKS.md`; the encoder subprocess + RTMP push, audio capture, and the frame-pipeline wiring follow (each its own PR) - Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED** — full plan in `TASKS.md`; WASAPI audio capture and the frame-pipeline wiring follow (each its own PR)
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead - `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
### Screen backdrop capture (TASK 3 ship task #1) ### Screen backdrop capture (TASK 3 ship task #1)
@@ -330,6 +330,30 @@ const). Constructor-injected search dirs / tools dir / downloader (`Func<string,
Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the
encoder step (not yet — this PR ships the seam + impl + tests only). encoder step (not yet — this PR ships the seam + impl + tests only).
### 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`
stderr lines into `StreamHealth` (bitrate/FPS/duration, dropped-from-frame-count). `FfmpegEncoder`
(`IFfmpegEncoder` seam) holds: `StartAsync` (locate → probe `-encoders` → spawn → stderr loop),
`SubmitFrameAsync` (serialized stdin writes under `SemaphoreSlim`), `StopAsync` (stdin EOF → ffmpeg
finalizes + exits by itself; a 10s watchdog kills it), `Dispose` (force-kill + wait), and the
`HealthUpdated`/`ProcessFailed` events. **Pattern:** the encoder never touches `Process` — it drives the
`IEncoderProcess` seam (`FfmpegEncoderProcess` wraps the real `Process`, redirected stdin/stdout/stderr
+ exit control); a `Func<IEncoderProcess>` factory + the locator are constructor-injected, so the
integration test fakes the whole subprocess (probe + encoder) with a Channel-backed `TextReader` whose
`Complete()` is EOF (`null`), never a `ChannelClosedException`.
**Decisions (locked):** args are pure (`FfmpegArgs.Build`, no string building in the encoder):
`-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` + a **silent placeholder
`-f lavfi -i anullsrc`** track (WASAPI capture, ship step 4, replaces it) + `-c:v <enc> -b:v K
-maxrate K -bufsize 2K` + **`-g fps×4 -keyint_min fps×4 -sc_threshold 0 -bf 0 -pix_fmt yuv420p`**
(≤4s keyframes, closed GOP, H.264 compliance) + `-c:a aac -ar 48000 -ac 2 -f flv <rtmpUrl>`.
**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
`GopSize` = FPS×4. Not yet constructed by the app — the frame-pipeline wiring (ship step 5) owns it.
### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md) ### Social bar (TASK 14 — shipped 2026-08-12, plan in TASKS.md)
A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the A **global bar layer** (never a Source, no sources-list row) that sits over the bottom or top of the
+290
View File
@@ -0,0 +1,290 @@
using System.Diagnostics;
using System.Threading.Channels;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The FFmpeg subprocess encoder (TASK 4 ship step 3). The integration test drives
/// the full lifecycle against fakes: encoder probe → subprocess start with the right
/// args → raw frames into stdin → stderr progress parsed into health → graceful stop.
/// The units pin down the pure pieces (args, progress parser, encoder picker) and
/// the failure edges. No real ffmpeg binary, no network.
/// </summary>
public class FfmpegEncoderTests
{
private sealed class QueuedReader : TextReader
{
private readonly Channel<string?> _channel = Channel.CreateUnbounded<string?>();
public void Enqueue(string s) => _channel.Writer.TryWrite(s);
public void Complete() => _channel.Writer.TryComplete();
public override string? ReadLine() => ReadLineAsync().GetAwaiter().GetResult();
public override async Task<string?> ReadLineAsync()
{
try
{
return await _channel.Reader.ReadAsync().AsTask().ConfigureAwait(false);
}
catch (ChannelClosedException)
{
return null; // stream EOF — the process exited and the pipe closed
}
}
}
private sealed class FakeEncoderProcess : IEncoderProcess
{
public ProcessStartInfo? StartInfo { get; private set; }
public MemoryStream Stdin { get; } = new();
public Stream StandardInput => Stdin;
public TextReader StandardOutput { get; }
public bool HasExited { get; private set; }
public int ExitCode { get; private set; }
public bool Killed { get; private set; }
public bool Started { get; private set; }
private readonly TaskCompletionSource _exit =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly QueuedReader _error = new();
public FakeEncoderProcess(string probeOutput = "")
=> StandardOutput = new StringReader(probeOutput);
public TextReader StandardError => _error;
public void EnqueueStderr(string line) => _error.Enqueue(line);
public void Start(ProcessStartInfo startInfo)
{
StartInfo = startInfo;
Started = true;
}
public void SignalExit(int code = 0)
{
ExitCode = code;
HasExited = true;
_error.Complete();
_exit.TrySetResult();
}
public void Kill()
{
Killed = true;
_error.Complete();
_exit.TrySetResult();
}
public Task WaitForExitAsync(CancellationToken cancellationToken = default) => _exit.Task;
public void Dispose()
{
_error.Complete();
_exit.TrySetResult();
}
}
private sealed class FakeEncoderProcessFactory
{
private readonly Queue<FakeEncoderProcess> _processes = new();
public void Return(FakeEncoderProcess p) => _processes.Enqueue(p);
public FakeEncoderProcess Create() => _processes.Dequeue();
}
private sealed class StubLocator : IFfmpegLocator
{
public Task<string> LocateAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(@"C:\tools\ffmpeg.exe");
}
private static byte[] BgraFrame(int w, int h, byte b, byte g, byte r)
{
var bytes = new byte[w * h * 4];
for (var i = 0; i < bytes.Length; i += 4)
{
bytes[i] = b;
bytes[i + 1] = g;
bytes[i + 2] = r;
bytes[i + 3] = 255;
}
return bytes;
}
[Fact]
public async Task Start_ProbesEncoder_FeedsFrames_ParsesHealth_StopsGracefully()
{
const string encoders =
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n" +
" V..... libopenh264 OpenH264 H.264 (codec h264)\n";
var probe = new FakeEncoderProcess(encoders);
var encoderProc = new FakeEncoderProcess();
var factory = new FakeEncoderProcessFactory();
factory.Return(probe);
factory.Return(encoderProc);
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
encoder.HealthUpdated += (_, h) => health.TrySetResult(h);
var options = new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc-xyz",
Width = 1920,
Height = 1080,
Fps = 60,
BitrateKbps = 8000,
};
await encoder.StartAsync(options);
Assert.True(probe.Started, "the -encoders probe must run");
Assert.True(encoderProc.Started, "the encoder subprocess must start");
Assert.Equal(@"C:\tools\ffmpeg.exe", encoderProc.StartInfo!.FileName);
var args = encoderProc.StartInfo.ArgumentList.ToArray();
var c = Array.IndexOf(args, "-c:v");
Assert.True(
args[c + 1] == "h264_nvenc",
"hardware NVENC must be preferred over the listed openh264");
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 255, 0, 0)));
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 0, 255, 0)));
Assert.Equal(2 * 1920 * 1080 * 4, encoderProc.Stdin.Length);
encoderProc.EnqueueStderr(
"frame= 120 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.00 bitrate= 4000.1kbits/s speed=1.00x");
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(4000.1, h.CurrentBitrate, 1);
Assert.Equal(59.9, h.FPS, 1);
Assert.Equal(TimeSpan.FromSeconds(2), h.StreamDuration);
encoderProc.SignalExit();
await encoder.StopAsync();
Assert.False(encoder.IsRunning);
Assert.False(encoderProc.Killed, "graceful stop must not kill the process");
Assert.Equal(StreamStatus.Offline, h.Status);
}
[Fact]
public async Task Start_WithoutRtmpUrl_Throws()
{
using var encoder = new FfmpegEncoder(new StubLocator());
await Assert.ThrowsAsync<ArgumentException>(() => encoder.StartAsync(new EncoderOptions()));
}
[Fact]
public async Task SubmitFrame_WhenNotRunning_Throws()
{
using var encoder = new FfmpegEncoder(new StubLocator());
await Assert.ThrowsAsync<InvalidOperationException>(
() => encoder.SubmitFrameAsync(new VideoFrame(2, 2, new byte[16])));
}
[Fact]
public async Task Stop_WithoutStart_IsNoop()
{
using var encoder = new FfmpegEncoder(new StubLocator());
await encoder.StopAsync();
Assert.False(encoder.IsRunning);
}
[Fact]
public async Task ProcessDeath_RaisesFailed()
{
var encoderProc = new FakeEncoderProcess();
var factory = new FakeEncoderProcessFactory();
factory.Return(new FakeEncoderProcess());
factory.Return(encoderProc);
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
encoder.ProcessFailed += (_, msg) => failed.TrySetResult(msg);
await encoder.StartAsync(new EncoderOptions { RtmpUrl = "rtmp://x/y" });
encoderProc.SignalExit(1);
var msg = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains("1", msg);
}
[Fact]
public void Args_Gop_IsFpsTimesFour()
{
var options = new EncoderOptions { Fps = 60 };
Assert.Equal(240, options.GopSize);
var args = FfmpegArgs.Build(options, "libopenh264").ToArray();
var g = Array.IndexOf(args, "-g");
Assert.Equal("240", args[g + 1]);
var keyint = Array.IndexOf(args, "-keyint_min");
Assert.Equal("240", args[keyint + 1]);
}
[Fact]
public void Args_IncludeInputOutputAndCompliance()
{
var args = FfmpegArgs.Build(
new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
Width = 1920,
Height = 1080,
Fps = 60,
BitrateKbps = 8000,
},
"libopenh264").ToArray();
Assert.Contains("-f", args);
Assert.Contains("rawvideo", args);
Assert.Contains("pipe:0", args);
Assert.Contains("1920x1080", args);
Assert.Contains("anullsrc=channel_layout=stereo:sample_rate=48000", args);
Assert.Contains("-sc_threshold", args);
Assert.Contains("-bf", args);
Assert.Contains("yuv420p", args);
Assert.Contains("aac", args);
Assert.Contains("flv", args);
Assert.Contains("rtmp://a.rtmp.youtube.com/live2/key", args);
}
[Fact]
public void ProgressParser_ParsesRealStatsLine()
{
var p = FfmpegProgressParser.TryParse(
"frame= 123 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.04 bitrate= 4000.1kbits/s speed=1.00x");
Assert.NotNull(p);
Assert.Equal(123, p.Value.Frame);
Assert.Equal(59.9, p.Value.Fps, 1);
Assert.Equal(4000.1, p.Value.BitrateKbps, 1);
Assert.Equal(2.04, p.Value.Duration.TotalSeconds, 2);
Assert.Equal(1024 * 1024, p.Value.SizeBytes);
}
[Fact]
public void ProgressParser_IgnoresBannerAndErrors()
{
Assert.Null(FfmpegProgressParser.TryParse("ffmpeg version 6.1 Copyright (c) 2000-2026 the FFmpeg developers"));
Assert.Null(FfmpegProgressParser.TryParse("Error while opening encoder for output stream #0:0"));
Assert.Null(FfmpegProgressParser.TryParse(""));
}
[Fact]
public void EncoderPicker_PrefersHardware_NeverLibx264()
{
var listing =
" V..... libx264 libx264 H.264 / AVC (codec h264)\n" +
" V..... libopenh264 OpenH264 H.264 (codec h264)\n" +
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n";
Assert.Equal("h264_nvenc", FfmpegEncoderPicker.Pick(listing));
var onlyGpl = " V..... libx264 libx264 H.264 / AVC (codec h264)\n";
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(onlyGpl));
var software = " V..... libopenh264 OpenH264 H.264 (codec h264)\n";
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick(software));
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick("no encoders at all"));
}
}