Slice 2 of TASK 65. 602 green, 0 warnings. **Clips are still off**: no `ClipBuffer` is passed, so the ffmpeg command line is byte-for-byte what it was before this commit, and a test asserts exactly that (the original args are a strict prefix of the new ones). - `FfmpegArgs` appends a fragmented-MP4 output to `pipe:1` only when `ClipBuffer` is set. One-second fragments (`-frag_duration 1000000`) rather than the 4 s GOP, so a clip can start inside the window instead of at the last keyframe. Stats already own stderr, so stdout was free. - `Fmp4FragmentSplitter` turns that byte stream into whole fragments: `ftyp`+`moov` is the init segment, each `moof`+`mdat` is a fragment. It parses box headers itself, survives a box being cut anywhere across reads, and **resyncs instead of throwing** — a clip fault must never take the stream down. - `FfmpegEncoder` reads it on its own task, never blocking the frame path. Any exception logs and disables clips for the session; the broadcast is untouched. - `IEncoderProcess` gained `StandardOutputBytes` (the seam already had the text reader for probing). Next slices: the replay layer + retention, then the Settings switch with disabled-but-remembered controls.
102 lines
3.5 KiB
C#
102 lines
3.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using Xunit;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Encoder;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 65 slice 2 — the clip mux as a side channel.
|
|
///
|
|
/// <para>Two things are pinned here and both protect the broadcast: the command line gains the clip
|
|
/// output ONLY when clips are enabled (otherwise ffmpeg's args are byte-for-byte what they were), and the
|
|
/// byte splitter turns ffmpeg's fragmented MP4 into whole fragments without ever seeing the stream path.</para>
|
|
/// </summary>
|
|
public sealed class ClipStreamTests
|
|
{
|
|
private static byte[] Box(string type, int payloadBytes)
|
|
{
|
|
var payload = Enumerable.Repeat((byte)0x41, payloadBytes).ToArray();
|
|
var box = new byte[8 + payloadBytes];
|
|
var size = box.Length;
|
|
box[0] = (byte)(size >> 24);
|
|
box[1] = (byte)(size >> 16);
|
|
box[2] = (byte)(size >> 8);
|
|
box[3] = (byte)size;
|
|
Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
|
|
payload.CopyTo(box, 8);
|
|
return box;
|
|
}
|
|
|
|
[Fact]
|
|
public void TheClipOutputIsAppendedOnlyWhenClipsAreEnabled()
|
|
{
|
|
var plain = FfmpegArgs.Build(Options(clip: null), "libopenh264");
|
|
var withClips = FfmpegArgs.Build(Options(clip: new ClipRing(1 << 20)), "libopenh264");
|
|
|
|
Assert.DoesNotContain("pipe:1", plain);
|
|
Assert.Contains("pipe:1", withClips);
|
|
|
|
// The stream block must not move: everything up to the clip output is identical.
|
|
// The clip block is appended, so the original command line is a strict prefix of the new one.
|
|
Assert.Equal(plain, withClips.Take(plain.Count).ToList());
|
|
}
|
|
|
|
[Fact]
|
|
public void SplitterFeedsInitAndWholeFragments_EvenWhenBytesArriveInPieces()
|
|
{
|
|
var ring = new ClipRing(1 << 20);
|
|
var splitter = new Fmp4FragmentSplitter(ring);
|
|
|
|
var stream = new List<byte>();
|
|
stream.AddRange(Box("ftyp", 16));
|
|
stream.AddRange(Box("moov", 32));
|
|
stream.AddRange(Box("moof", 8));
|
|
stream.AddRange(Box("mdat", 40));
|
|
stream.AddRange(Box("moof", 8));
|
|
stream.AddRange(Box("mdat", 40));
|
|
|
|
// Deliberately ragged writes: a box must survive being cut anywhere.
|
|
var bytes = stream.ToArray();
|
|
var offset = 0;
|
|
foreach (var chunk in new[] { 3, 7, 11, 29, 1000 })
|
|
{
|
|
var take = Math.Min(chunk, bytes.Length - offset);
|
|
if (take <= 0) break;
|
|
splitter.Write(bytes.AsSpan(offset, take));
|
|
offset += take;
|
|
}
|
|
if (offset < bytes.Length) splitter.Write(bytes.AsSpan(offset));
|
|
|
|
Assert.Equal(2, ring.FragmentCount);
|
|
|
|
var snapshot = ring.Snapshot();
|
|
Assert.True(snapshot.Length > 0);
|
|
Assert.Equal("ftyp", Encoding.ASCII.GetString(snapshot, 4, 4)); // init comes first
|
|
}
|
|
|
|
[Fact]
|
|
public void MdatWithoutAMoofIsNotReplayable_AndIsIgnored()
|
|
{
|
|
var ring = new ClipRing(1 << 20);
|
|
var splitter = new Fmp4FragmentSplitter(ring);
|
|
splitter.Write(Box("ftyp", 8));
|
|
splitter.Write(Box("moov", 8));
|
|
splitter.Write(Box("mdat", 32)); // no moof → not a fragment
|
|
|
|
Assert.Empty(ring.Snapshot()); // init alone is not a clip
|
|
Assert.Equal(0, ring.FragmentCount);
|
|
}
|
|
|
|
private static EncoderOptions Options(ClipRing? clip) => new()
|
|
{
|
|
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
|
|
Width = 1920, Height = 1080, Fps = 30, BitrateKbps = 6000,
|
|
StreamEnabled = true,
|
|
ClipBuffer = clip,
|
|
};
|
|
}
|