109 lines
9.1 KiB
C#
109 lines
9.1 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.Enigma2;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
/// <summary>Native source dimensions through slate/live switches, remux and fMP4 packaging.</summary>
|
|
public static class ResolutionE2E
|
|
{
|
|
private static string encoder, output;
|
|
private static int active, maximum;
|
|
private static readonly Dictionary<string, string> Sizes = new() {
|
|
["sd"] = "720x576", ["hd"] = "1280x720", ["fhd"] = "1920x1080", ["qhd"] = "2560x1440", ["uhd"] = "3840x2160"
|
|
};
|
|
public static async Task Run(string executable, string folder)
|
|
{
|
|
encoder = executable; output = Path.GetFullPath(folder);
|
|
if (Directory.Exists(output)) throw new Exception("Choose a new output directory"); Directory.CreateDirectory(output);
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop();
|
|
var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
|
|
using var logging = LoggerFactory.Create(b => b.AddSimpleConsole().SetMinimumLevel(LogLevel.Warning));
|
|
using var manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), logging.CreateLogger<ContinuityStreams>(), preserveResolution: true);
|
|
using var relay = new LiveStreamRelay(new System.Net.Http.HttpClient(), port, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5));
|
|
builder.Services.AddSingleton(manager); builder.Services.AddSingleton(relay); builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
|
|
await using var app = builder.Build(); app.MapControllers(); app.MapGet("/fixture/{size}.ts", Fixture); await app.StartAsync();
|
|
var reports = new List<object>();
|
|
try
|
|
{
|
|
foreach (var (name, size) in Sizes)
|
|
{
|
|
var dimensions = size.Split('x');
|
|
var tracks = LosslessSlateTests.Tracks(int.Parse(dimensions[0]), int.Parse(dimensions[1]), 25);
|
|
((BroadcastMediaStream)tracks.Input[0]).SampleAspectRatio = name == "sd" ? "16:15" : "1:1";
|
|
tracks = new BroadcastTracks(tracks.Input);
|
|
var media = await manager.Open(relay.Register($"http://127.0.0.1:{port}/fixture/{name}.ts"), "en", CancellationToken.None, tracks);
|
|
Require(media.MediaStreams[0].Width == tracks.Input[0].Width && media.MediaStreams[0].Height == tracks.Input[0].Height,
|
|
"source metadata does not falsely cap the channel at Full HD: " + name);
|
|
var target = Path.Combine(output, name); Directory.CreateDirectory(target);
|
|
var common = new[] { "-xerror", "-i", media.Path, "-t", "32", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy" };
|
|
using var recording = Start(common.Concat(new[] { Path.Combine(target, "recording.ts") }), name + "-recording");
|
|
using var packaging = Start(common.Concat(new[] { "-bsf:a", "aac_adtstoasc", "-f", "hls", "-hls_segment_type", "fmp4", "-hls_time", "2", "-hls_list_size", "0", Path.Combine(target, "index.m3u8") }), name + "-fmp4");
|
|
try
|
|
{
|
|
await Task.WhenAll(recording.WaitForExitAsync(), packaging.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65));
|
|
Console.WriteLine("Source status: " + JsonSerializer.Serialize(manager.Status()));
|
|
Require(recording.ExitCode == 0 && packaging.ExitCode == 0, "strict slate/live read and fMP4 packaging: " + size);
|
|
}
|
|
finally
|
|
{
|
|
if (!recording.HasExited) recording.Kill(true); if (!packaging.HasExited) packaging.Kill(true); manager.Close(media.Id);
|
|
}
|
|
foreach (var file in new[] { "recording.ts", "index.m3u8" })
|
|
{
|
|
using var decode = Start(new[] { "-xerror", "-i", Path.Combine(target, file), "-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-" }, name + "-decode-" + Path.GetExtension(file).TrimStart('.'));
|
|
await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60)); Require(decode.ExitCode == 0, "decode mixed-resolution " + file + ": " + size);
|
|
var probePath = Path.Combine(Path.GetDirectoryName(encoder), "ffprobe");
|
|
var info = new ProcessStartInfo(probePath) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
|
|
foreach (var arg in new[] { "-v", "error", "-threads", "1", "-select_streams", "v:0", "-show_entries", "frame=width,height,sample_aspect_ratio", "-of", "csv=p=0", Path.Combine(target, file) }) info.ArgumentList.Add(arg);
|
|
using var probe = Process.Start(info); var text = await probe.StandardOutput.ReadToEndAsync(); var error = await probe.StandardError.ReadToEndAsync(); await probe.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60));
|
|
Require(probe.ExitCode == 0 && string.IsNullOrWhiteSpace(error), "probe actual decoded dimensions: " + name + " " + file);
|
|
var decodedDimensions = text.Split('\n').Where(line => line.Length > 0).Select(line => line.Trim()).Distinct().ToArray();
|
|
var expected = size.Replace('x', ',') + ",";
|
|
Require(decodedDimensions.Any(line => line.StartsWith(expected, StringComparison.Ordinal)), "native dimensions reach the decoded client frames: " + size);
|
|
if (name == "sd") Require(decodedDimensions.Any(line => line.StartsWith(expected + "16:15", StringComparison.Ordinal)), "SD sample aspect ratio survives slate/live switching");
|
|
reports.Add(new { Profile = name, File = file, Dimensions = decodedDimensions });
|
|
}
|
|
await Task.Delay(1500);
|
|
}
|
|
Require(active == 0 && maximum == 1, "resolution trials keep one upstream and release the last source");
|
|
var report = new { Passed = true, SyntheticOnly = true, LowMotionFixture = true, ReceiverConnectionsOpened = 0, PhysicalAndroidTvTested = false,
|
|
ActualJellyfinPlayerTested = false, MaximumConcurrentSourceConnections = maximum, Reports = reports };
|
|
await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report));
|
|
}
|
|
finally { manager.Dispose(); await app.StopAsync(); }
|
|
}
|
|
private static Process Start(IEnumerable<string> arguments, string name)
|
|
{
|
|
var info = new ProcessStartInfo(encoder) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true };
|
|
foreach (var arg in new[] { "-hide_banner", "-loglevel", "warning", "-nostdin", "-threads", "1", "-filter_threads", "1" }.Concat(arguments)) info.ArgumentList.Add(arg);
|
|
var process = Process.Start(info); process.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (typeof(ResolutionE2E)) File.AppendAllText(Path.Combine(output, name + ".log"), e.Data + "\n"); }; process.BeginErrorReadLine(); return process;
|
|
}
|
|
private static void Require(bool condition, string text) { if (!condition) throw new Exception(text); Console.WriteLine("PASS " + text); }
|
|
private static async Task Fixture(HttpContext context)
|
|
{
|
|
var name = context.Request.RouteValues["size"]?.ToString(); if (name == null || !Sizes.TryGetValue(name, out var size)) { context.Response.StatusCode = 404; return; }
|
|
maximum = Math.Max(maximum, Interlocked.Increment(ref active));
|
|
var filter = "color=c=0x085f21:s=" + size + ":r=25" + (name == "sd" ? ",setsar=16/15" : "");
|
|
var info = new ProcessStartInfo(encoder) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true };
|
|
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", filter,
|
|
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-ac", "2", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
|
|
using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t";
|
|
try { await process.StandardOutput.BaseStream.CopyToAsync(context.Response.Body, context.RequestAborted); }
|
|
catch (OperationCanceledException) { } catch (IOException) { }
|
|
finally { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync(); Interlocked.Decrement(ref active); }
|
|
}
|
|
}
|