Preserve channel resolution through continuity slates up to UHD
This commit is contained in:
@@ -21,13 +21,13 @@ public static class ContinuityE2E
|
||||
private static string ffmpeg;
|
||||
private static volatile bool enabled;
|
||||
private static int connections, active, maximum;
|
||||
public static async Task Run(string encoder, string output, bool longTest = false)
|
||||
public static async Task Run(string encoder, string output, bool longTest = false, bool preserveResolution = false)
|
||||
{
|
||||
ffmpeg = encoder; output = Path.GetFullPath(output);
|
||||
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 streams = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), NullLogger.Instance);
|
||||
using var streams = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), NullLogger.Instance, preserveResolution: preserveResolution);
|
||||
using var relay = new LiveStreamRelay(new System.Net.Http.HttpClient(), port, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5));
|
||||
builder.Services.AddSingleton(relay); builder.Services.AddSingleton(streams); builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
|
||||
await using var app = builder.Build(); app.MapControllers(); app.MapGet("/fixture.ts", Fixture); await app.StartAsync();
|
||||
@@ -81,7 +81,7 @@ public static class ContinuityE2E
|
||||
}
|
||||
var report = new { Passed = true, SyntheticOnly = true, ReceiverConnectionsOpened = 0, ViewerLanguages = new[] { "pl", "en" }, SourceConnections = connections, FailureSeconds = longTest ? 75 : 15,
|
||||
MaximumConcurrentSourceConnections = maximum, RemainingConnections = active, WaitingLossRecoverySamePlayer = true,
|
||||
StrictVideoAudioDecode = true, Width = ContinuityStreams.Width, Height = ContinuityStreams.Height, FrameRate = ContinuityStreams.FrameRate,
|
||||
StrictVideoAudioDecode = true, PreserveSourceResolution = preserveResolution, Width = preserveResolution ? 1280 : ContinuityStreams.Width, Height = preserveResolution ? 720 : ContinuityStreams.Height, FrameRate = ContinuityStreams.FrameRate,
|
||||
PhysicalAndroidTvTested = false, ActualJellyfinPlayerTested = false };
|
||||
await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
public static class NativeResolutionPolicyTests
|
||||
{
|
||||
public static void Run(Action<bool, string> check)
|
||||
{
|
||||
// Exercise the installed Jellyfin 12.2 negotiation code, including
|
||||
// devices whose width/height conditions are optional rather than required.
|
||||
foreach (var (width, height) in new[] { (720, 576), (1280, 720), (1920, 1080), (2560, 1440), (3840, 2160) })
|
||||
foreach (var required in new[] { true, false })
|
||||
{
|
||||
var source = new MediaSourceInfo { Id = "test", Path = "http://127.0.0.1/test.m3u8", Protocol = MediaProtocol.Http,
|
||||
Container = "hls", SupportsDirectPlay = false, SupportsDirectStream = true, SupportsProbing = false,
|
||||
MediaStreams = new[] { new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", AverageFrameRate = 50, Profile = "Constrained Baseline" },
|
||||
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Channels = 2, SampleRate = 48000, IsDefault = true } } };
|
||||
var profile = new DeviceProfile { Name = "Resolution contract", MaxStreamingBitrate = 100000000,
|
||||
DirectPlayProfiles = new[] { new DirectPlayProfile { Container = "hls,ts", Type = DlnaProfileType.Video, VideoCodec = "h264", AudioCodec = "aac" } },
|
||||
TranscodingProfiles = new[] { new TranscodingProfile { Container = "ts", Type = DlnaProfileType.Video, VideoCodec = "h264", AudioCodec = "aac", Protocol = MediaStreamProtocol.hls, Context = EncodingContext.Streaming } },
|
||||
CodecProfiles = new[] { new CodecProfile { Codec = "h264", Type = CodecType.Video, Conditions = new[] {
|
||||
new ProfileCondition { Property = ProfileConditionValue.Width, Condition = ProfileConditionType.LessThanEqual, Value = width.ToString(), IsRequired = required },
|
||||
new ProfileCondition { Property = ProfileConditionValue.Height, Condition = ProfileConditionType.LessThanEqual, Value = height.ToString(), IsRequired = required } } } } };
|
||||
var support = DispatchProxy.Create<ITranscoderSupport, ResolutionTranscoder>();
|
||||
var stream = new StreamBuilder(support, NullLogger.Instance).GetOptimalVideoStream(new MediaOptions {
|
||||
ItemId = Guid.NewGuid(), DeviceId = "test", Profile = profile, MediaSources = new[] { source }, EnableDirectPlay = true,
|
||||
EnableDirectStream = true, AllowVideoStreamCopy = true, AllowAudioStreamCopy = true, MaxBitrate = 100000000, Context = EncodingContext.Streaming });
|
||||
check(stream.MaxWidth == width && stream.MaxHeight == height && source.MediaStreams[0].Width == null,
|
||||
$"native Jellyfin negotiates {width}x{height} without a plugin Full HD cap; required={required}");
|
||||
}
|
||||
}
|
||||
}
|
||||
public class ResolutionTranscoder : DispatchProxy
|
||||
{
|
||||
protected override object Invoke(MethodInfo method, object[] args) => method.ReturnType == typeof(bool) ? true : method.ReturnType == typeof(int) ? 2 : null;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
if (args.Length == 3 && args[0] == "--native-continuity-e2e") { await ContinuityE2E.Run(args[1], args[2], true, true); return; }
|
||||
if (args.Length == 3 && args[0] == "--resolution-e2e") { await ResolutionE2E.Run(args[1], args[2]); return; }
|
||||
if (args.Length == 3 && (args[0] == "--continuity-e2e" || args[0] == "--long-continuity-e2e")) { await ContinuityE2E.Run(args[1], args[2], args[0].StartsWith("--long")); return; }
|
||||
var time=DateTime.UtcNow;
|
||||
void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);}
|
||||
@@ -129,6 +131,7 @@ await SavedChannelPlaybackTests.Run(Check);
|
||||
await ChannelSyncTests.Run(Check);
|
||||
await PlaybackFeedbackTests.Run(Check);
|
||||
ContinuityTests.Run(Check);
|
||||
NativeResolutionPolicyTests.Run(Check);
|
||||
public class PathsProxy : DispatchProxy
|
||||
{
|
||||
public string Folder;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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 manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), NullLogger.Instance, 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 media = await manager.Open(relay.Register($"http://127.0.0.1:{port}/fixture/{name}.ts"), "en", CancellationToken.None);
|
||||
Require(media.MediaStreams[0].Width == null && media.MediaStreams[0].Height == null && media.MediaStreams[0].Level == null,
|
||||
"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));
|
||||
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 dimensions = text.Split('\n').Where(line => line.Length > 0).Select(line => line.Trim()).Distinct().ToArray();
|
||||
var expected = size.Replace('x', ',') + ",";
|
||||
Require(dimensions.Any(line => line.StartsWith(expected, StringComparison.Ordinal)), "native dimensions reach the decoded client frames: " + size);
|
||||
if (name == "sd") Require(dimensions.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 = dimensions });
|
||||
}
|
||||
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", "-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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user