From ba427e85e1ae2922592463cfbb9eb3b246a66998 Mon Sep 17 00:00:00 2001 From: gramps Date: Wed, 12 Aug 2026 20:32:24 -0700 Subject: [PATCH] =?UTF-8?q?TASK=204=20ship=20step=203:=20encoder=20+=20RTM?= =?UTF-8?q?P=20push=20=E2=80=94=20FFmpeg=20subprocess=20with=20probed=20H.?= =?UTF-8?q?264=20picker,=20BGRA=20stdin=20feed,=20stderr=20health=20parsin?= =?UTF-8?q?g,=20graceful=20stop=20=E2=80=94=20122=20tests=20passing,=200?= =?UTF-8?q?=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 91 +++---- Services/Encoder/EncoderOptions.cs | 27 +++ Services/Encoder/FfmpegArgs.cs | 46 ++++ Services/Encoder/FfmpegEncoder.cs | 263 ++++++++++++++++++++ Services/Encoder/FfmpegEncoderPicker.cs | 35 +++ Services/Encoder/FfmpegEncoderProcess.cs | 38 +++ Services/Encoder/FfmpegProgressParser.cs | 47 ++++ Services/Encoder/IEncoderProcess.cs | 26 ++ Services/Encoder/IFfmpegEncoder.cs | 25 ++ Services/index.md | 8 + TASKS.md | 31 ++- ai.md | 26 +- ytLive.Tests/FfmpegEncoderTests.cs | 290 +++++++++++++++++++++++ 13 files changed, 891 insertions(+), 62 deletions(-) create mode 100644 Services/Encoder/EncoderOptions.cs create mode 100644 Services/Encoder/FfmpegArgs.cs create mode 100644 Services/Encoder/FfmpegEncoder.cs create mode 100644 Services/Encoder/FfmpegEncoderPicker.cs create mode 100644 Services/Encoder/FfmpegEncoderProcess.cs create mode 100644 Services/Encoder/FfmpegProgressParser.cs create mode 100644 Services/Encoder/IEncoderProcess.cs create mode 100644 Services/Encoder/IFfmpegEncoder.cs create mode 100644 ytLive.Tests/FfmpegEncoderTests.cs diff --git a/HANDOFF.md b/HANDOFF.md index 769d60d..df7d4ab 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,69 +7,40 @@ ## Session state (last updated: 2026-08-12) -- **Branch:** `main` — the social bar v2 push is **committed + pushed** (`9873eed`, - 18 files, +1878/−321, 112 tests passing, 0 warnings). Working tree is **dirty - with the post-push docs backfill only** (ai.md, README.md, TASKS.md schema - section, THIRD-PARTY-NOTICES.txt) — ready to commit when the user says so. -- **Finished this session (round 3):** user's live-test report said the mastodon - icon still didn't show for `@gramps@llamachile.tube`. Root cause: the identity - domain `llamachile.tube` is YunoHost-SSO-gated — `/.well-known/nodeinfo`, - `/@gramps`, and webfinger all answer with the SSO login page, so nodeinfo - returned nothing and the icon fell back to the honeycomb glyph. The real - instance lives at `mastodon.llamachile.tube`, and only the bare root - `https://llamachile.tube/` 302s to it ("default app" redirect). Fix: - `HttpSocialValidator.TryFetchFediverseSoftwareAsync` now, when nodeinfo on the - identity domain fails, follows the root redirect (`ResolveInstanceHostAsync`, - 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. +- **Branch:** `main`. TASK 4 ship step 3 (encoder + RTMP push) is **built and + tested** but **NOT committed** — the working tree is dirty with the encoder + (10 new files in `Services/Encoder/` + `ytLive.Tests/FfmpegEncoderTests.cs`) and + its memory updates (TASKS.md, ai.md, Services/index.md). Ready to commit when + the user says so. +- **Finished this session:** TASK 4 ship step 3 — the FFmpeg subprocess encoder: + `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/ + `FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/ + `FfmpegEncoderPicker` in `Services/Encoder/`. StartAsync (locate → probe + `-encoders` → spawn → stderr loop), SubmitFrameAsync (serialized BGRA stdin), + StopAsync (stdin EOF → ffmpeg finalizes; 10s kill watchdog), ProcessFailed on + unexpected non-zero exit. Not yet constructed by the app (ship step 5 wiring). + Build: **0 warnings**. Tests: **122 passing** (was 112; +10 new). - **Landmines:** - - `LayoutStore.Socials` is only populated by `Load()` — tests must call - `store.Load()` before asserting it. - - `DetectService("justaname")` → **Website** with empty handle; only - unparseable input or `@user@domain` yields Link/Fediverse. - - Dialog sign-in provider is `Func>`; test fakes must - return `Task.FromResult` (sync-completed) so the fire-and-forget command - settles before the next assert. - - `SocialBarBottomTop = 1040` literal lives in `MainWindow.xaml.cs` (the VM's - `MasterFrameHeight` is private). - - `Cancel_AbortsInFlightValidation_WithoutMutatingSlot` relies on the - BlockingValidator's gate completing synchronously (no - `RunContinuationsAsynchronously`) — the assertions run after - `Gate.TrySetResult` returns because the awaited continuation executes inline. - - Nodeinfo cancellation test cancels mid-lookup via a counting stub; the - post-fetch `ct.IsCancellationRequested` check is what reports `Canceled`. - - Round-3 redirect fallback fires only when identity-domain nodeinfo fails; - the root follow happens automatically (HttpClient default auto-redirect), - 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. + - `ChannelReader.ReadAsync` on a completed channel **throws** + `ChannelClosedException` — it does NOT return `null` like a StreamReader EOF. + The test fake (`QueuedReader` in `FfmpegEncoderTests.cs`) catches it and + returns `null`, or the encoder's stderr loop treats it as a fault and + `ProcessFailed` never fires (that's exactly what happened on the first run — + see commit history). + - The probe process (`FakeEncoderProcess`) must be a *separate* `IEncoderProcess` + instance from the encoder process in `Start_*` tests — `StartAsync` calls the + factory twice (probe → encoder), and the fake can't simulate both roles at + once. + - `StopAsync` waits the full `ExitTimeout` if the fake's process doesn't signal + exit — fakes must call `SignalExit()` inside `StopAsync`'s stdin-EOF path. + - Windows-only: `FfmpegEncoderProcess` sets `UseShellExecute=false` + + `RedirectStandardXxx=true` — never spawn with a shell. +- **Next step:** TASK 4 ship step 4 — WASAPI audio capture (loopback + mic) + feeding `AudioLevel` (req 7). 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`; 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 `http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`. diff --git a/Services/Encoder/EncoderOptions.cs b/Services/Encoder/EncoderOptions.cs new file mode 100644 index 0000000..4d6c887 --- /dev/null +++ b/Services/Encoder/EncoderOptions.cs @@ -0,0 +1,27 @@ +namespace ytLive.Services.Encoder; + +/// +/// Everything the FFmpeg subprocess encoder needs for one go-live (TASK 4 ship +/// step 3). is the FULL ingestion URL — the reusable +/// stream's ingest address plus its stream key (rtmp://a.rtmp.youtube.com/live2/<key>). +/// Resolution/FPS/bitrate come from the active quality tier; audio is a silent +/// placeholder track until the WASAPI capture step replaces the input. +/// +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; + + /// H.264 encoder name for -c:v; the encoder probes and prefers + /// nvenc → qsv → amf → libopenh264 when not forced. + public string? VideoEncoder { get; init; } + + public int AudioSampleRate { get; init; } = 48000; + public int AudioChannels { get; init; } = 2; + + /// GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound. + public int GopSize => Fps * 4; +} diff --git a/Services/Encoder/FfmpegArgs.cs b/Services/Encoder/FfmpegArgs.cs new file mode 100644 index 0000000..ea95ed3 --- /dev/null +++ b/Services/Encoder/FfmpegArgs.cs @@ -0,0 +1,46 @@ +namespace ytLive.Services.Encoder; + +/// +/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3): +/// raw BGRA frames via stdin (paced -re), silent placeholder audio via lavfi +/// anullsrc (the WASAPI step replaces this input), H.264 + AAC encoding, FLV +/// muxing to the ingestion URL. Pure — the encoder just starts +/// ffmpeg.exe [Build(...)]. +/// +public static class FfmpegArgs +{ + public static IReadOnlyList 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, + ]; + } +} diff --git a/Services/Encoder/FfmpegEncoder.cs b/Services/Encoder/FfmpegEncoder.cs new file mode 100644 index 0000000..489e448 --- /dev/null +++ b/Services/Encoder/FfmpegEncoder.cs @@ -0,0 +1,263 @@ +using System.Diagnostics; +using ytLive.Helpers; +using ytLive.Models; + +namespace ytLive.Services.Encoder; + +/// +/// The default : spawns ffmpeg.exe (resolved via +/// ), feeds raw BGRA frames into stdin, and parses the +/// -stats progress lines into . Encoder choice is +/// probed from the binary's -encoders listing (hardware NVENC/QSV/AMF first, +/// OpenH264 software fallback — never libx264, see the license posture) unless +/// 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. +/// +public sealed class FfmpegEncoder : IFfmpegEncoder +{ + public event EventHandler? HealthUpdated; + public event EventHandler? ProcessFailed; + + private readonly IFfmpegLocator _locator; + private readonly Func _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? 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); + } + + /// + /// 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). + /// + 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 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); + } +} diff --git a/Services/Encoder/FfmpegEncoderPicker.cs b/Services/Encoder/FfmpegEncoderPicker.cs new file mode 100644 index 0000000..aa0d2d4 --- /dev/null +++ b/Services/Encoder/FfmpegEncoderPicker.cs @@ -0,0 +1,35 @@ +namespace ytLive.Services.Encoder; + +/// +/// Picks the best available H.264 encoder from ffmpeg's -encoders listing, +/// honoring the license posture (no GPL libx264): hardware NVENC → QSV → AMF, then +/// the OpenH264 software fallback. Pure parser — the -encoders probe output is +/// fetched by the encoder via an and fed here. +/// +public static class FfmpegEncoderPicker +{ + /// Preference order, best first. All ship in the pinned BtbN lgpl-shared build. + public static readonly string[] Preference = + [ + "h264_nvenc", + "h264_qsv", + "h264_amf", + "libopenh264", + ]; + + /// + /// First 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. + /// + 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]; + } +} diff --git a/Services/Encoder/FfmpegEncoderProcess.cs b/Services/Encoder/FfmpegEncoderProcess.cs new file mode 100644 index 0000000..9f7172e --- /dev/null +++ b/Services/Encoder/FfmpegEncoderProcess.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; +using System.IO; + +namespace ytLive.Services.Encoder; + +/// +/// The real : a with all three +/// std streams redirected. Constructed by for both the +/// encoder subprocess and the -encoders probe. +/// +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(); +} diff --git a/Services/Encoder/FfmpegProgressParser.cs b/Services/Encoder/FfmpegProgressParser.cs new file mode 100644 index 0000000..8dd9431 --- /dev/null +++ b/Services/Encoder/FfmpegProgressParser.cs @@ -0,0 +1,47 @@ +using System.Text.RegularExpressions; + +namespace ytLive.Services.Encoder; + +/// A decoded ffmpeg -stats progress line (pure data). +public readonly record struct FfmpegProgress( + long Frame, + double Fps, + double BitrateKbps, + TimeSpan Duration, + long SizeBytes); + +/// +/// Pure parser for ffmpeg's periodic frame= fps= size= time= bitrate= stderr +/// lines (the -stats/-stats_period output). Unit-tested in isolation +/// so the encoder loop stays a thin wire. +/// +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*(?\d+)\s+fps=\s*(?[\d.]+).*?" + + @"size=\s*(?\d+)KiB.*?" + + @"time=(?