Preserve native broadcast video and add patient shared stream startup
This commit is contained in:
@@ -27,7 +27,6 @@ public sealed class ContinuityStreams : IDisposable
|
||||
private readonly string root;
|
||||
private Func<string> encoderPath;
|
||||
private readonly int port;
|
||||
private readonly bool preserveResolution;
|
||||
private readonly StartupMessages messages;
|
||||
private readonly ILogger logger;
|
||||
private readonly object gate = new();
|
||||
@@ -45,7 +44,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
// Explicit inputs support an isolated FFmpeg/Kestrel test without a receiver or server configuration changes.
|
||||
public ContinuityStreams(string encoder, string root, int port, StartupMessages messages, ILogger logger, bool preserveResolution = true)
|
||||
{
|
||||
this.preserveResolution = preserveResolution; encoderPath = () => encoder; this.root = Path.GetFullPath(root); this.port = port; this.messages = messages; this.logger = logger;
|
||||
encoderPath = () => encoder; this.root = Path.GetFullPath(root); this.port = port; this.messages = messages; this.logger = logger;
|
||||
Directory.CreateDirectory(this.root); sweeper = Task.Run(Sweep);
|
||||
}
|
||||
private sealed class Viewer
|
||||
@@ -59,9 +58,16 @@ public sealed class ContinuityStreams : IDisposable
|
||||
public async Task<MediaSourceInfo> Open(string relayUrl, string language, CancellationToken token, BroadcastTracks tracks = null, bool useSlates = true)
|
||||
{
|
||||
language = StartupMessages.ResolveLanguage(language, null, "en");
|
||||
tracks ??= BroadcastTracks.Fallback(preserveResolution);
|
||||
if (tracks.Normalize != useSlates) throw new ArgumentException("Track format does not match continuity mode");
|
||||
var clips = useSlates ? await CachedSlates(language, token, tracks) : null;
|
||||
tracks ??= BroadcastTracks.Fallback();
|
||||
var profile = useSlates ? LosslessSlateProfile.Create(tracks) : null;
|
||||
useSlates = profile != null;
|
||||
Dictionary<string, byte[]> clips = null;
|
||||
if (useSlates)
|
||||
{
|
||||
try { clips = await CachedSlates(language, token, tracks); }
|
||||
catch (Exception error) when (error is not OperationCanceledException && !token.IsCancellationRequested)
|
||||
{ useSlates = false; logger.LogWarning("[Enigma2] Status picture unavailable ({FailureType}); preserving the original broadcast", error.GetType().Name); }
|
||||
}
|
||||
var sourceKey = relayUrl + "|" + useSlates + "|" + tracks.Key;
|
||||
token.ThrowIfCancellationRequested();
|
||||
Viewer viewer;
|
||||
@@ -78,7 +84,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!useSlates) await viewer.Source.Ready.Task.WaitAsync(TimeSpan.FromSeconds(35), token);
|
||||
if (!useSlates) await viewer.Source.Ready.Task.WaitAsync(TimeSpan.FromSeconds(StartupBudget.Seconds), token);
|
||||
return new MediaSourceInfo {
|
||||
Id = viewer.Id, Path = $"http://127.0.0.1:{port}/DecoderIntegration/Continuity/{viewer.Id}/index.m3u8",
|
||||
Protocol = MediaProtocol.Http, Container = "hls", IsInfiniteStream = true, SupportsProbing = false,
|
||||
@@ -93,7 +99,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
}
|
||||
private async Task<Dictionary<string, byte[]>> CachedSlates(string language, CancellationToken token, BroadcastTracks tracks = null)
|
||||
{
|
||||
tracks ??= BroadcastTracks.Fallback(preserveResolution);
|
||||
tracks ??= PreviewTracks();
|
||||
var key = language + "-" + tracks.Key;
|
||||
if (slates.Count >= 64)
|
||||
foreach (var stale in slates.Keys.Where(k => k != key).Take(slates.Count - 63)) slates.TryRemove(stale, out _);
|
||||
@@ -134,7 +140,8 @@ public sealed class ContinuityStreams : IDisposable
|
||||
viewer.LastAccess = DateTime.UtcNow;
|
||||
var segment = viewer.Source.Timeline.Find(sequence);
|
||||
if (segment == null) return null;
|
||||
var transport = TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k, alignAudio: viewer.Source.UseSlates);
|
||||
var transport = TransportStreamClock.Rebase(segment.Slate is { } slate ? viewer.Slates[slate] : segment.Video, segment.Start90k,
|
||||
alignAudio: segment.Slate != null, offset90k: segment.ClockOffset90k);
|
||||
viewer.SubtitleVersions.Apply(transport, sequence);
|
||||
return transport;
|
||||
}
|
||||
@@ -164,10 +171,16 @@ public sealed class ContinuityStreams : IDisposable
|
||||
foreach (var source in sources.Values) source.Stop(); sources.Clear(); viewers.Clear();
|
||||
}
|
||||
}
|
||||
private IEnumerable<string> CodecArguments(bool slate = false) => new[] {
|
||||
private static BroadcastTracks PreviewTracks() => new(new MediaStream[] {
|
||||
new BroadcastMediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Width = Width, Height = Height,
|
||||
Profile = "Constrained Baseline", PixelFormat = "yuv420p", AverageFrameRate = FrameRate, SampleAspectRatio = "1:1", FrameRateRational = "50/1" },
|
||||
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Profile = "LC", Channels = 2, SampleRate = 48000, IsDefault = true }
|
||||
});
|
||||
// These arguments encode the generated status image only. Live AV always uses -c copy.
|
||||
private static IEnumerable<string> SlateCodecArguments(LosslessSlateProfile profile) => new[] {
|
||||
"-c:v", "libx264", "-threads", "2", "-preset", "ultrafast", "-tune", "zerolatency", "-crf", "20",
|
||||
"-maxrate", preserveResolution && !slate ? "48M" : "12M", "-bufsize", preserveResolution && !slate ? "96M" : "24M", "-profile:v", "baseline", "-pix_fmt", "yuv420p",
|
||||
"-r", FrameRate.ToString(CultureInfo.InvariantCulture), "-g", "112", "-keyint_min", "112", "-sc_threshold", "0",
|
||||
"-profile:v", profile.Profile, "-pix_fmt", "yuv420p", "-r", profile.RateArgument,
|
||||
"-g", profile.Gop.ToString(CultureInfo.InvariantCulture), "-keyint_min", profile.Gop.ToString(CultureInfo.InvariantCulture), "-sc_threshold", "0",
|
||||
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k" };
|
||||
private Process Start(IEnumerable<string> arguments, string folder)
|
||||
{
|
||||
@@ -185,16 +198,19 @@ public sealed class ContinuityStreams : IDisposable
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
var profile = LosslessSlateProfile.Create(tracks) ?? throw new InvalidOperationException("No lossless status profile");
|
||||
var seconds = profile.SegmentSeconds.ToString("0.########", CultureInfo.InvariantCulture);
|
||||
var font = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
|
||||
if (!File.Exists(font)) throw new FileNotFoundException("Continuity slates require a Unicode DejaVu Sans font");
|
||||
var clips = new Dictionary<string, byte[]>();
|
||||
foreach (var phase in new[] { "waiting", "retrying" })
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "message.txt"), messages.Text(language, "slate_" + phase), lifetime.Token);
|
||||
var filter = $"drawbox=x=(iw-160)/2:y=260:w=160:h=100:color=0x58bfee:t=6,drawtext=fontfile={font}:text=TV:fontsize=42:fontcolor=0x58bfee:x=(w-tw)/2:y=285," +
|
||||
$"drawtext=fontfile={font}:textfile=message.txt:fontsize=42:fontcolor=white:line_spacing=22:x=(w-tw)/2:y=490";
|
||||
using var process = Start(new[] { "-f", "lavfi", "-i", $"color=c=0x101723:s={Width}x{Height}:r={FrameRate}", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-vf", filter,
|
||||
"-map", "0:v:0" }.Concat(Enumerable.Range(0, tracks.AudioCount).SelectMany(_ => new[] { "-map", "1:a:0" })).Concat(new[] { "-t", "6.72" }).Concat(CodecArguments(slate: true)).Concat(tracks.MetadataArguments(audioVideoOnly: true)).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "0", "-hls_segment_filename", "slate-%d.ts", "slate.m3u8" }), folder);
|
||||
var size = Math.Max(12, profile.Height / 28);
|
||||
var filter = $"setsar={profile.Aspect},drawtext=fontfile={font}:text=TV:fontsize={size}:fontcolor=0x58bfee:x=(w-tw)/2:y=h*0.25," +
|
||||
$"drawtext=fontfile={font}:textfile=message.txt:fontsize={size}:fontcolor=white:line_spacing={size / 2}:x=(w-tw)/2:y=h*0.45";
|
||||
using var process = Start(new[] { "-f", "lavfi", "-i", $"color=c=0x101723:s={profile.Width}x{profile.Height}:r={profile.RateArgument}", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-vf", filter,
|
||||
"-map", "0:v:0" }.Concat(Enumerable.Range(0, tracks.AudioCount).SelectMany(_ => new[] { "-map", "1:a:0" })).Concat(new[] { "-t", (profile.SegmentSeconds * 3).ToString("0.########", CultureInfo.InvariantCulture) }).Concat(SlateCodecArguments(profile)).Concat(tracks.MetadataArguments(audioVideoOnly: true)).Concat(new[] { "-f", "hls", "-hls_time", seconds, "-hls_list_size", "0", "-hls_segment_filename", "slate-%d.ts", "slate.m3u8" }), folder);
|
||||
try { await process.WaitForExitAsync(lifetime.Token).WaitAsync(TimeSpan.FromSeconds(20), lifetime.Token); }
|
||||
finally { if (!process.HasExited) process.Kill(entireProcessTree: true); }
|
||||
if (process.ExitCode != 0) throw new IOException("FFmpeg slate generation failed");
|
||||
@@ -219,7 +235,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
public ContinuityTimeline Timeline { get; }
|
||||
public volatile bool Retrying;
|
||||
public Source(ContinuityStreams owner, string url, string key, BroadcastTracks tracks, bool useSlates)
|
||||
{ this.owner = owner; Url = url; Key = key; Tracks = tracks; UseSlates = useSlates; Timeline = new(DateTime.UtcNow, useSlates); lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
|
||||
{ this.owner = owner; Url = url; Key = key; Tracks = tracks; UseSlates = useSlates; Timeline = new(DateTime.UtcNow, useSlates, LosslessSlateProfile.Create(tracks)?.SegmentSeconds ?? ContinuityTimeline.SegmentSeconds); lifetime = CancellationTokenSource.CreateLinkedTokenSource(owner.lifetime.Token); task = Task.Run(Run); }
|
||||
public void Stop() { try { lifetime.Cancel(); } catch (ObjectDisposedException) { } }
|
||||
private async Task Run()
|
||||
{
|
||||
@@ -233,12 +249,9 @@ public sealed class ContinuityStreams : IDisposable
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
var args = new[] { "-rw_timeout", "10000000", "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url }
|
||||
var args = new[] { "-rw_timeout", (StartupBudget.StallSeconds * 1000000L).ToString(CultureInfo.InvariantCulture), "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url }
|
||||
.Concat(Tracks.MapArguments()).Concat(new[] { "-dn" });
|
||||
if (UseSlates)
|
||||
args = args.Concat(new[] { "-vf", owner.preserveResolution ? "bwdif=mode=send_field:parity=auto:deint=interlaced" : $"bwdif=mode=send_field:parity=auto:deint=interlaced,scale=w={Width}:h={Height}:force_original_aspect_ratio=decrease,pad={Width}:{Height}:(ow-iw)/2:(oh-ih)/2,setsar=1" })
|
||||
.Concat(owner.CodecArguments()).Concat(new[] { "-c:s", "copy" });
|
||||
else args = args.Concat(new[] { "-c", "copy" });
|
||||
args = args.Concat(new[] { "-c", "copy" });
|
||||
// MPEG-TS segmenter preserves DVB/teletext subtitles, unlike the HLS muxer's WebVTT-only subtitle output.
|
||||
args = args.Concat(Tracks.MetadataArguments()).Concat(new[] { "-max_interleave_delta", "1000000", "-f", "segment", "-segment_format", "mpegts", "-segment_time", "2.24",
|
||||
"-segment_list", "live.m3u8", "-segment_list_type", "m3u8", "-segment_list_size", "6", "-segment_list_flags", "+live", "segment-%09d.ts" });
|
||||
@@ -246,7 +259,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
using var process = owner.Start(args, folder);
|
||||
try
|
||||
{
|
||||
var highest = 0; // Discard the encoder-priming segment.
|
||||
var highest = -1; // Stream copy has no encoder-priming segment to discard.
|
||||
var lastOutput = DateTime.UtcNow;
|
||||
while (!process.HasExited && !lifetime.IsCancellationRequested)
|
||||
{
|
||||
@@ -268,7 +281,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
lastOutput = DateTime.UtcNow; delivered = true; Ready.TrySetResult(); Retrying = false; failures = 0; Interlocked.Increment(ref owner.liveSegments);
|
||||
}
|
||||
}
|
||||
if ((DateTime.UtcNow - lastOutput).TotalSeconds > (delivered ? 12 : 35)) throw new IOException("No usable encoded segments");
|
||||
if ((DateTime.UtcNow - lastOutput).TotalSeconds > (delivered ? StartupBudget.StallSeconds + 5 : StartupBudget.Seconds)) throw new IOException("No usable encoded segments");
|
||||
await Task.Delay(250, lifetime.Token);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user