TASK 18: local (record-only) recording pipeline + top-bar pills/light/Start-End button UX

Encoder dual-output: StreamEnabled/RecordEnabled/RecordPath options; per-output
ffmpeg block (stream ->flv, record ->mp4); throw unless at least one output.

RecordingFile: auto-name ty-<yyyymmdd>-<start hhmm>-0000.mp4, rename-on-stop to
ty-...-<len hhmmss>.mp4 with numeric-suffix on collision. LayoutStore persists
record folder. MainViewModel: REC/ON-AIR pills, status light, single Start<->End
button (replaces Start/Stop variants), StopStream no longer signs out, explicit
session timer. About overlay widened. Sign In label + spacing before Start.

Tests: 244 passed / 246 (2 pre-existing: AudioPipeline gains-mute, RoundClip).
Build 0 warnings. Scope check clean.

Memory-map correction (derived-solution rule, 2026-08-29): image-shrink recipe was
never recorded when first done, so it was re-derived. Added Derived-solution/recipes
rule to AGENTS.md; MyMistakes.md now a permanent recipes registry (records the
verified WPF-imaging shrink recipe); schema.md rows MyMistakes as registry; ai.md
points to the rule. README hero image replaced: 3.2MB screenshot -> 1400x794 ->
188KB docs/ytLlive-preview.jpg (JPEG q82).
This commit is contained in:
2026-08-29 09:02:55 -07:00
parent 1e633f9bd9
commit a9eb360288
20 changed files with 708 additions and 151 deletions
+13
View File
@@ -19,6 +19,19 @@ public sealed class EncoderOptions
public int Fps { get; init; } = 60;
public int BitrateKbps { get; init; } = 8000;
/// <summary>Push to <see cref="RtmpUrl"/> (the ON-AIR side). Defaults on so
/// the pre-recording callers behave unchanged; the VM sets it from the ON-AIR
/// pill.</summary>
public bool StreamEnabled { get; init; } = true;
/// <summary>Write a local file at <see cref="RecordPath"/> (the REC side).
/// False unless the REC pill is on.</summary>
public bool RecordEnabled { get; init; }
/// <summary>Local MP4 output path for recording. Required when
/// <see cref="RecordEnabled"/> is true.</summary>
public string? RecordPath { get; init; }
/// <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; }
+39 -29
View File
@@ -12,40 +12,50 @@ 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",
var args = new List<string>
{
"-hide_banner", "-loglevel", "info",
"-stats", "-stats_period", "0.5",
"-re",
"-f", "rawvideo",
"-pix_fmt", "bgra",
"-f", "rawvideo", "-pix_fmt", "bgra",
"-video_size", $"{options.Width}x{options.Height}",
"-framerate", options.Fps.ToString(),
"-i", "pipe:0",
"-f", "f32le",
"-ar", options.AudioSampleRate.ToString(),
"-f", "f32le", "-ar", options.AudioSampleRate.ToString(),
"-ac", options.AudioChannels.ToString(),
"-i", $@"\\.\pipe\{options.AudioPipeName}",
"-map", "0:v",
"-map", "1:a",
"-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,
];
};
// Each output is a self-contained block: its own -map, codec options,
// format, and destination. Record+stream encodes the video twice off the
// one raw input pipe (cheap on NVENC).
if (options.StreamEnabled)
{
AddVideoTags(args, options, videoEncoder);
args.AddRange(["-c:a", "aac", "-b:a", "128k",
"-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]);
args.AddRange(["-f", "flv", options.RtmpUrl]);
}
if (options.RecordEnabled)
{
AddVideoTags(args, options, videoEncoder);
args.AddRange(["-c:a", "aac",
"-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]);
args.AddRange(["-f", "mp4", options.RecordPath!]);
}
return args;
}
private static void AddVideoTags(List<string> args, EncoderOptions options, string videoEncoder)
{
var gop = options.GopSize;
args.AddRange(["-map", "0:v", "-map", "1:a"]);
args.AddRange(["-c:v", videoEncoder]);
args.AddRange(["-b:v", $"{options.BitrateKbps}k", "-maxrate", $"{options.BitrateKbps}k",
"-bufsize", $"{options.BitrateKbps * 2}k"]);
args.AddRange(["-g", gop.ToString(), "-keyint_min", gop.ToString(),
"-sc_threshold", "0", "-bf", "0", "-pix_fmt", "yuv420p"]);
}
}
+6 -2
View File
@@ -43,8 +43,12 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
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));
var hasStream = options.StreamEnabled && !string.IsNullOrWhiteSpace(options.RtmpUrl);
var hasRecord = options.RecordEnabled && !string.IsNullOrWhiteSpace(options.RecordPath);
if (!hasStream && !hasRecord)
throw new ArgumentException(
"At least one output is required — a stream needs an RTMP URL, a recording needs an output path.",
nameof(options));
lock (_gate)
{
+1 -1
View File
@@ -87,7 +87,7 @@ public sealed class FramePump : IDisposable
var options = _encoderOptions();
if (options == null)
{
_log?.Invoke("FramePump: no RTMP URL available (live-stream create lands in TASK 5) — encoder skipped");
_log?.Invoke("FramePump: no output configured (neither streaming nor recording) — encoder skipped");
lock (_gate) _started = false;
return;
}
+24
View File
@@ -735,6 +735,30 @@ public class LayoutStore : IDisposable
insert.ExecuteNonQuery();
}
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
public string? LoadRecordFolder()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'RecordFolder';";
var value = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public void SaveRecordFolder(string? folder)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'RecordFolder';";
cmd.ExecuteNonQuery();
if (string.IsNullOrWhiteSpace(folder))
return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('RecordFolder', $folder);";
insert.Parameters.AddWithValue("$folder", folder);
insert.ExecuteNonQuery();
}
/// <summary>The cached reusable stream (TASK 5), or null if never created.
/// Survives restarts so the pump has its RTMP URL the moment go-live starts
/// instead of waiting on the YouTube round-trip.</summary>
+27
View File
@@ -0,0 +1,27 @@
namespace ytLive.Services;
/// <summary>
/// Pure filename logic for local recordings (TASK 18). The user-requested naming
/// is <c>ty-yyyymmdd-hhmm-hh2mm2.mp4</c>: <c>ty</c> is the brand marker, the first
/// <c>hhmm</c> is the hour+minute the recording STARTED (saved), and the second
/// <c>hh2mm2</c> is the hour+minute of the recording's LENGTH. Length is only known
/// when recording ends, so the file is created with a <c>0000</c> length and renamed
/// on stop (see <see cref="FinalizeFileName"/>).
/// </summary>
public static class RecordingFile
{
/// <summary>A start placeholder — the length is filled in on stop.</summary>
public const string StartLengthSuffix = "0000";
public static string BuildStartName(DateTime start)
=> $"ty-{start:yyyyMMdd}-{start:HHmm}-{StartLengthSuffix}";
/// <summary>Final name with the real recording length (fractional hour+minute),
/// e.g. 75 minutes → <c>ty-20260829-1405-0115.mp4</c>.</summary>
public static string BuildFinalName(DateTime start, TimeSpan length)
{
var hh2 = (int)length.TotalHours;
var mm2 = length.Minutes;
return $"ty-{start:yyyyMMdd}-{start:HHmm}-{hh2:00}{mm2:00}";
}
}