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:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -196,7 +196,7 @@ public class FramePumpTests
|
||||
|
||||
Assert.False(pump.IsRunning);
|
||||
Assert.Equal(0, encoder.StartCount);
|
||||
Assert.Contains(log, m => m.Contains("RTMP"));
|
||||
Assert.Contains(log, m => m.Contains("no output configured"));
|
||||
|
||||
await pump.StopAsync(); // no-op after a skipped start
|
||||
Assert.Equal(0, encoder.StopCount);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Local recording filename rules (TASK 18): <c>ty-<yyyyMMdd>-<HHmm start>-<hh2mm2 length>.mp4</c>.
|
||||
/// Recordings start with a <c>0000</c> length placeholder and are renamed on stop.
|
||||
/// </summary>
|
||||
public class RecordingFileTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildStartName_UsesDateHourAndMinute_WithZeroLength()
|
||||
{
|
||||
var start = new DateTime(2026, 8, 29, 14, 5, 0);
|
||||
Assert.Equal("ty-20260829-1405-0000", RecordingFile.BuildStartName(start));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildStartName_PadsSingleDigitHourAndMinute()
|
||||
{
|
||||
var start = new DateTime(2026, 1, 2, 9, 7, 0);
|
||||
Assert.Equal("ty-20260102-0907-0000", RecordingFile.BuildStartName(start));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFinalName_ShortRecording_IsHhmmOfLength()
|
||||
{
|
||||
var start = new DateTime(2026, 8, 29, 14, 5, 0);
|
||||
var length = TimeSpan.FromMinutes(2);
|
||||
Assert.Equal("ty-20260829-1405-0002", RecordingFile.BuildFinalName(start, length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFinalName_HourAndMinutes_ConcatenatesHh2Mm2()
|
||||
{
|
||||
var start = new DateTime(2026, 8, 29, 14, 5, 0);
|
||||
var length = new TimeSpan(1, 15, 0); // 1h 15m
|
||||
Assert.Equal("ty-20260829-1405-0115", RecordingFile.BuildFinalName(start, length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFinalName_OverFlowHours_ShowsTotalHours()
|
||||
{
|
||||
var start = new DateTime(2026, 8, 29, 14, 5, 0);
|
||||
var length = new TimeSpan(12, 30, 0); // 12h 30m
|
||||
Assert.Equal("ty-20260829-1405-1230", RecordingFile.BuildFinalName(start, length));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user