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'">