Preserve channel resolution through continuity slates up to UHD

This commit is contained in:
2026-10-23 23:05:57 +02:00
parent 5f6b0b5ff6
commit 9a056b3070
14 changed files with 182 additions and 24 deletions
@@ -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>
+12 -11
View File
@@ -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'">
+9
View File
@@ -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.
+3 -3
View File
@@ -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));
}
+41
View File
@@ -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;
}
+3
View File
@@ -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;
+102
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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'
+3 -2
View File
@@ -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),
+2 -2
View File
@@ -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),