Preserve channel resolution through continuity slates up to UHD
This commit is contained in:
@@ -33,6 +33,7 @@ namespace Jellyfin.Plugin.Enigma2.Configuration
|
||||
public bool EnableAutomaticChannelSync { get; set; } = true;
|
||||
public bool ShowNativeStartupMessages { get; set; } = true;
|
||||
public bool EnableContinuitySlates { get; set; } = false;
|
||||
public bool PreserveSourceResolution { get; set; } = true;
|
||||
|
||||
|
||||
public PluginConfiguration()
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
<input is="emby-checkbox" type="checkbox" id="chkEnableContinuitySlates" />
|
||||
<span>Podtrzymuj stream planszą FFmpeg podczas oczekiwania i przerw</span>
|
||||
</label>
|
||||
<div class="fieldDescription">Ponawia odbiór aż do zatrzymania przez widza. Jeden proces odbioru na kanał; język planszy według interfejsu użytkownika, następnie profilu i ustawień Jellyfina. Normalizuje obraz do H264 Full HD 50 kl./s i dźwięk do AAC stereo; wymaga dodatkowego CPU, nie zachowuje 4K, HDR, dźwięku przestrzennego ani dodatkowych ścieżek. Nie dotyczy nagrań.</div>
|
||||
<div class="fieldDescription">Ponawia odbiór aż do zatrzymania przez widza. Jeden proces odbioru na kanał; język planszy według interfejsu użytkownika, następnie profilu i ustawień Jellyfina. Zachowuje rozdzielczość obrazu źródła, również 4K, przy kodowaniu H264 50 kl./s i AAC stereo. Wymaga CPU; nie zachowuje HDR, dźwięku przestrzennego ani dodatkowych ścieżek. Nie dotyczy nagrań.</div>
|
||||
<label class="checkboxContainer">
|
||||
<input is="emby-checkbox" type="checkbox" id="chkShowNativeStartupMessages" />
|
||||
<span>Pokazuj komunikaty uruchamiania kanału w Android TV</span>
|
||||
|
||||
@@ -27,6 +27,7 @@ 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();
|
||||
@@ -40,11 +41,11 @@ public sealed class ContinuityStreams : IDisposable
|
||||
private long attempts, liveSegments;
|
||||
public const int Width = 1920, Height = 1080, FrameRate = 50;
|
||||
public ContinuityStreams(IMediaEncoder encoder, IApplicationPaths paths, IServerApplicationHost host, StartupMessages messages, ILogger<ContinuityStreams> logger)
|
||||
: this(encoder.EncoderPath, Path.Combine(paths.CachePath, "enigma2-continuity-v1"), host.HttpPort, messages, logger) { encoderPath = () => encoder.EncoderPath; }
|
||||
: this(encoder.EncoderPath, Path.Combine(paths.CachePath, "enigma2-continuity-v1"), host.HttpPort, messages, logger, Plugin.Instance?.Configuration?.PreserveSourceResolution ?? true) { encoderPath = () => encoder.EncoderPath; }
|
||||
// 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)
|
||||
public ContinuityStreams(string encoder, string root, int port, StartupMessages messages, ILogger logger, bool preserveResolution = true)
|
||||
{
|
||||
encoderPath = () => encoder; this.root = Path.GetFullPath(root); this.port = port; this.messages = messages; this.logger = logger;
|
||||
this.preserveResolution = preserveResolution; 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
|
||||
@@ -76,8 +77,8 @@ public sealed class ContinuityStreams : IDisposable
|
||||
Protocol = MediaProtocol.Http, Container = "hls", IsInfiniteStream = true, SupportsProbing = false,
|
||||
SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, BufferMs = 1000,
|
||||
MediaStreams = new[] {
|
||||
new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Profile = "Constrained Baseline", Level = 42,
|
||||
Width = Width, Height = Height, AverageFrameRate = FrameRate, RealFrameRate = FrameRate, IsInterlaced = false, BitRate = 12000000, PixelFormat = "yuv420p" },
|
||||
new MediaStream { Type = MediaStreamType.Video, Index = -1, Codec = "h264", Profile = "Constrained Baseline", Level = preserveResolution ? null : 42,
|
||||
Width = preserveResolution ? null : Width, Height = preserveResolution ? null : Height, AverageFrameRate = FrameRate, RealFrameRate = FrameRate, IsInterlaced = false, BitRate = preserveResolution ? null : 12000000, PixelFormat = "yuv420p" },
|
||||
new MediaStream { Type = MediaStreamType.Audio, Index = -1, Codec = "aac", Channels = 2, SampleRate = 48000, BitRate = 128000, IsDefault = true }
|
||||
}
|
||||
};
|
||||
@@ -149,9 +150,9 @@ public sealed class ContinuityStreams : IDisposable
|
||||
foreach (var source in sources.Values) source.Stop(); sources.Clear(); viewers.Clear();
|
||||
}
|
||||
}
|
||||
private static IEnumerable<string> CodecArguments() => new[] {
|
||||
private IEnumerable<string> CodecArguments(bool slate = false) => new[] {
|
||||
"-c:v", "libx264", "-threads", "2", "-preset", "ultrafast", "-tune", "zerolatency", "-crf", "20",
|
||||
"-maxrate", "12M", "-bufsize", "24M", "-profile:v", "baseline", "-level:v", "4.2", "-pix_fmt", "yuv420p",
|
||||
"-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",
|
||||
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k" };
|
||||
private Process Start(IEnumerable<string> arguments, string folder)
|
||||
@@ -179,7 +180,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
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", "-map", "1:a:0", "-t", "6.72" }.Concat(CodecArguments()).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "0", "-hls_segment_filename", "slate-%d.ts", "slate.m3u8" }), folder);
|
||||
"-map", "0:v:0", "-map", "1:a:0", "-t", "6.72" }.Concat(CodecArguments(slate: true)).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-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");
|
||||
@@ -217,8 +218,8 @@ public sealed class ContinuityStreams : IDisposable
|
||||
Directory.CreateDirectory(folder);
|
||||
var args = new[] { "-rw_timeout", "10000000", "-analyzeduration", "5000000", "-probesize", "4000000", "-i", Url,
|
||||
"-map", "0:v:0", "-map", "0:a:0", "-sn", "-dn", "-vf",
|
||||
$"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(CodecArguments()).Concat(new[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "6", "-hls_delete_threshold", "8", "-hls_flags", "delete_segments+temp_file+omit_endlist", "-hls_segment_filename", "segment-%09d.ts", "live.m3u8" });
|
||||
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[] { "-f", "hls", "-hls_time", "2.24", "-hls_list_size", "6", "-hls_delete_threshold", "8", "-hls_flags", "delete_segments+temp_file+omit_endlist", "-hls_segment_filename", "segment-%09d.ts", "live.m3u8" });
|
||||
Interlocked.Increment(ref owner.attempts);
|
||||
using var process = owner.Start(args, folder);
|
||||
try
|
||||
@@ -237,7 +238,7 @@ public sealed class ContinuityStreams : IDisposable
|
||||
var match = Regex.Match(line, @"^segment-(\d{9})\.ts$");
|
||||
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var index) || index <= highest) continue;
|
||||
var path = Path.Combine(folder, line);
|
||||
if (!File.Exists(path) || new FileInfo(path).Length > 8 * 1024 * 1024) continue;
|
||||
if (!File.Exists(path) || new FileInfo(path).Length > 32 * 1024 * 1024) continue;
|
||||
Timeline.Live(await File.ReadAllBytesAsync(path, lifetime.Token), epoch, duration, DateTime.UtcNow);
|
||||
highest = index; lastOutput = DateTime.UtcNow; delivered = true; Retrying = false; failures = 0; Interlocked.Increment(ref owner.liveSegments);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class ContinuityTimeline
|
||||
}
|
||||
public void Live(byte[] video, string epoch, double duration, DateTime now)
|
||||
{
|
||||
if (video == null || video.Length == 0 || video.Length > 8 * 1024 * 1024 || duration <= 0 || duration > 5) throw new ArgumentException("Invalid live segment");
|
||||
if (video == null || video.Length == 0 || video.Length > 32 * 1024 * 1024 || duration <= 0 || duration > 5) throw new ArgumentException("Invalid live segment");
|
||||
lock (gate) { lastLive = now; Add(video, null, epoch, duration, now); }
|
||||
}
|
||||
public void Tick(DateTime now, bool retrying)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AssemblyVersion>7.0.12.0</AssemblyVersion>
|
||||
<FileVersion>7.0.12.0</FileVersion>
|
||||
<AssemblyVersion>7.0.13.0</AssemblyVersion>
|
||||
<FileVersion>7.0.13.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
|
||||
|
||||
@@ -109,3 +109,12 @@ Moduł web pokazuje stan do pierwszej odtworzonej klatki, umożliwia anulowanie
|
||||
**Koszt i zakres:** tryb jest domyślnie wyłączony w nowych konfiguracjach. Włączenie normalizuje kanał do H264 1920×1080 50 kl./s, CRF20 z maksimum 12 Mb/s, AAC 48 kHz stereo. Nie zachowuje 4K, HDR, wielokanałowego audio, dodatkowych ścieżek ani napisów. Wymaga programowego kodowania; pomiary dotyczą konkretnego testu. Wyłączenie przywraca dotychczasowy odbiór. Nie zmienia konfiguracji Jellyfina, wrapperów, profili dekodera ani OSCam.
|
||||
|
||||
Weryfikacja: `Tests` obejmuje izolację użytkowników/urządzeń, uprawnienia, migrację kanałów i wcześniejsze mechanizmy streamu, katalog języków, zegar TS i ograniczoną pamięć przy symulowanej sześciogodzinnej awarii. `test-web-bridge.cjs` sprawdza web bez kont produkcyjnych. `experiments/run-continuity-e2e.py` uruchamia izolowany harness z prawdziwym FFmpeg, dwoma językami i sterowanym źródłem syntetycznym; opcja `--long` utrzymuje awarię 75 s, sprawdza odzyskanie obrazu, rygorystyczny odczyt AV i wszystkie 12 plansz. Nie jest to test fizycznego Android TV ani produkcyjnej sesji Jellyfina. Należy jawnie odróżnić przygotowany kandydat od wersji aktywnej na serwerze.
|
||||
|
||||
|
||||
### 7.0.13 — zachowanie rozdzielczości źródła
|
||||
|
||||
Na żądanie użytkownika usunięto sztywny limit Full HD z domyślnego trybu plansz. `PreserveSourceResolution` domyślnie wynosi true: filtr producenta nie skaluje ani nie zmienia SAR kanału. Rozdzielczość początkowej planszy nie jest już wpisywana jako rozmiar źródła do natywnej negocjacji Jellyfina. Profile urządzeń mogą nadal ograniczać wyjście zgodnie z możliwościami klienta i jego ustawieniami jakości. Nie ustawiono sztywnego limitu szerokości/wysokości producenta; maksymalny segment zwiększono do 32 MiB, a limit przepływności kodera do 48 Mb/s. Kodek i dźwięk pozostają H264 8-bit, 50 kl./s i AAC stereo — ta zmiana nie dodaje zachowania HDR, oryginalnego kodeka, ścieżek audio ani napisów.
|
||||
|
||||
Izolowana próba z rzeczywistym FFmpeg potwierdziła przejścia plansza → 720×576, 1280×720, 1920×1080, 2560×1440 i 3840×2160. Odczytano rzeczywiste wymiary zdekodowanych klatek zarówno z MPEG-TS, jak i HLS fMP4, a dla SD również SAR16:15. To test syntetyczny o małej zmienności obrazu, nie dowód wydajności każdego kanału UHD ani test fizycznej aplikacji. Dodatkowa próba z zachowaniem 720p potwierdziła 75 sekund awarii, właściwe języki plansz i odzyskanie obrazu bez ponownego otwierania klienta. Testy wykorzystują również natywny StreamBuilder Jellyfina12.2 do sprawdzenia limitów urządzeń od SD do UHD.
|
||||
|
||||
7.0.13 jest kandydatem do odrębnego zatwierdzenia; nie nadpisuje automatycznie wdrożonej7.0.11. Wcześniejsza zgoda dotyczyła tylko nocnego Jellytel. Nie aktywować starego kandydata7.0.12 ograniczonego do Full HD.
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ guid: "193f29f9-ea6c-4595-a6f6-55e79d7c590a"
|
||||
# The version of the plugin, starting at 1. Note that the AssemblyVersion
|
||||
# and FileVersion flags in the `.csproj` file must have two additional
|
||||
# fiels, e.g. 1.0.0 to be built correctly.
|
||||
version: "7.0.12.0" # Please increment with each pull request
|
||||
version: "7.0.13.0" # Please increment with each pull request
|
||||
|
||||
# The supported Jellyfin version, usually the earliest binary-compatible
|
||||
# version. Based on the Jellyfin components from NuGet.
|
||||
|
||||
@@ -13,7 +13,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = '7.0.12.0'
|
||||
VERSION = '7.0.13.0'
|
||||
ABI = '12.2.0.0'
|
||||
GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a'
|
||||
DLL = 'Jellyfin.Plugin.Enigma2.dll'
|
||||
|
||||
@@ -17,13 +17,14 @@ def main():
|
||||
parser.add_argument('--ffmpeg', default='/usr/lib/jellyfin-ffmpeg/ffmpeg')
|
||||
parser.add_argument('--output', required=True)
|
||||
parser.add_argument('--long', action='store_true')
|
||||
parser.add_argument('--native', action='store_true')
|
||||
args = parser.parse_args()
|
||||
root = Path(args.output).resolve()
|
||||
if root.exists():
|
||||
raise ValueError('Choose a new output directory')
|
||||
samples, previous = [], {}
|
||||
with Path(str(root) + '.log').open('x') as log:
|
||||
process = subprocess.Popen([args.harness, '--long-continuity-e2e' if args.long else '--continuity-e2e', args.ffmpeg, str(root)], stdout=log,
|
||||
process = subprocess.Popen([args.harness, '--native-continuity-e2e' if args.native else '--long-continuity-e2e' if args.long else '--continuity-e2e', args.ffmpeg, str(root)], stdout=log,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
deadline = time.monotonic() + 240
|
||||
try:
|
||||
@@ -58,7 +59,7 @@ def main():
|
||||
report = {'harness_exit_code': process.returncode, 'samples': samples,
|
||||
'scope': 'Only synthetic plugin source encoding; excludes fixture and player decoders'}
|
||||
(root / 'cpu-sample.json').write_text(json.dumps(report, indent=2))
|
||||
if process.returncode == 0 and args.long:
|
||||
if process.returncode == 0 and (args.long or args.native):
|
||||
subprocess.run(['python3', str(Path(__file__).with_name('verify-continuity-recording.py')), '--output', str(root), '--ffmpeg', args.ffmpeg], check=True)
|
||||
print(Path(str(root) + '.log').read_text()[-3500:])
|
||||
print(json.dumps({'exit_code': process.returncode, 'cpu_samples': len(samples),
|
||||
|
||||
@@ -12,7 +12,7 @@ import subprocess
|
||||
def pixels(ffmpeg, path, second=0):
|
||||
command = [ffmpeg, '-hide_banner', '-loglevel', 'error', '-nostdin', '-threads', '1', '-filter_threads', '1',
|
||||
'-ss', str(second), '-i', str(path), '-frames:v', '1', '-vf',
|
||||
'crop=1920:260:0:430,scale=640:86:flags=area', '-pix_fmt', 'gray', '-f', 'rawvideo', 'pipe:1']
|
||||
'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,crop=1920:260:0:430,scale=640:86:flags=area', '-pix_fmt', 'gray', '-f', 'rawvideo', 'pipe:1']
|
||||
output = subprocess.run(command, capture_output=True, check=True, timeout=20).stdout
|
||||
if len(output) != 640 * 86:
|
||||
raise RuntimeError('Incomplete video frame')
|
||||
@@ -33,7 +33,7 @@ def verify(root, ffmpeg):
|
||||
retrying = pixels(ffmpeg, root / (language + '-retrying-slate.ts'))
|
||||
observed_waiting = pixels(ffmpeg, root / (language + '.mkv'), 1)
|
||||
observed_retrying = pixels(ffmpeg, root / (language + '.mkv'), 70)
|
||||
live = pixels(ffmpeg, root / (language + '.mkv'), 39)
|
||||
live = max((pixels(ffmpeg, root / (language + '.mkv'), at) for at in (18,22,26,30,34,38,42,46)), key=lambda frame: difference(retrying,frame))
|
||||
recovered = pixels(ffmpeg, root / (language + '.mkv'), 140)
|
||||
metrics = {'waiting_error': difference(waiting, observed_waiting),
|
||||
'retrying_error': difference(retrying, observed_retrying),
|
||||
|
||||
Reference in New Issue
Block a user