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
+50
View File
@@ -175,6 +175,56 @@ public class FfmpegEncoderTests
await Assert.ThrowsAsync<ArgumentException>(() => encoder.StartAsync(new EncoderOptions()));
}
[Fact]
public void Args_RecordOnly_SkipsRtmp_WritesMp4()
{
var args = FfmpegArgs.Build(
new EncoderOptions
{
StreamEnabled = false,
RecordEnabled = true,
RecordPath = @"C:\videos\ty-20260829-1405-0000.mp4",
Width = 1920,
Height = 1080,
Fps = 60,
BitrateKbps = 8000,
},
"libopenh264").ToArray();
Assert.DoesNotContain("flv", args);
Assert.DoesNotContain("rtmp", args);
var idx = Array.IndexOf(args, "mp4");
Assert.True(idx > -1, "record-only must emit a -f mp4 output");
Assert.Equal("-f", args[idx - 1]);
Assert.Equal(@"C:\videos\ty-20260829-1405-0000.mp4", args[idx + 1]);
}
[Fact]
public void Args_DualOutput_HasTwoMapBlocks_FlvAndMp4()
{
var args = FfmpegArgs.Build(
new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
StreamEnabled = true,
RecordEnabled = true,
RecordPath = @"C:\videos\ty-20260829-1405-0000.mp4",
Width = 1280,
Height = 720,
Fps = 30,
BitrateKbps = 4000,
},
"h264_nvenc").ToArray();
// two video maps (one per output block)
Assert.Equal(2, args.Count(a => a == "0:v"));
Assert.Equal(2, args.Count(a => a == "-c:v"));
Assert.Contains("flv", args);
Assert.Contains("mp4", args);
Assert.Contains("rtmp://a.rtmp.youtube.com/live2/key", args);
Assert.Contains(@"C:\videos\ty-20260829-1405-0000.mp4", args);
}
[Fact]
public async Task SubmitFrame_WhenNotRunning_Throws()
{