From 3fd34ace9463f836499e0414dfff62971fdaf5a7 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:04:13 -0500 Subject: [PATCH] feat: next output frame rate --- CHANGELOG.md | 4 ++ .../Streaming/ChannelConfigConverter.cs | 51 ++++++++++++++++++- .../Commands/StartFFmpegNextSessionHandler.cs | 13 +++-- .../Streaming/IChannelConfigConverter.cs | 2 + .../PrepareTroubleshootingPlaybackHandler.cs | 1 + ErsatzTV.Core/Next/Config/ChannelConfig.cs | 8 +++ .../Streaming/ChannelConfigConverterTests.cs | 47 ++++++++++++++++- 7 files changed, 119 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e7523904..41c2a61a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `mpeg2video` encoder support (software, some Intel QSV and Intel VAAPI devices) - Add song playback support, using legacy-generated background image and text - Song Video Mode `With Progress` is not supported yet; these songs play without a progress bar + - Support FFmpeg Profile `Normalize Frame Rate` + - As with the legacy engine, the target is the lowest frame rate above 23 fps among the channel's scheduled items + - Frames are dropped or duplicated to reach the target, instead of blended + - With video copy, only items already at the target rate are copied; others are transcoded - Add `Deep Scan Library` button to local libraries - A deep scan re-probes every file and refreshes all metadata and artwork, even when files appear unchanged - This forces a refresh of e.g. NFO files or posters that were edited without changing their modified time diff --git a/ErsatzTV.Application/Streaming/ChannelConfigConverter.cs b/ErsatzTV.Application/Streaming/ChannelConfigConverter.cs index e8da7be00..0bc3627b1 100644 --- a/ErsatzTV.Application/Streaming/ChannelConfigConverter.cs +++ b/ErsatzTV.Application/Streaming/ChannelConfigConverter.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.IO.Abstractions; using ErsatzTV.Application.Channels; using ErsatzTV.Application.FFmpegProfiles; @@ -6,6 +7,7 @@ using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Next.Config; +using ErsatzTV.FFmpeg; using Subtitle = ErsatzTV.Core.Next.Config.Subtitle; namespace ErsatzTV.Application.Streaming; @@ -13,11 +15,12 @@ namespace ErsatzTV.Application.Streaming; public class ChannelConfigConverter(IConfigElementRepository configElementRepository, IFileSystem fileSystem) : IChannelConfigConverter { - public static readonly string ChannelConfigVersion = "https://ersatztv.org/channel/version/0.1.0"; + public static readonly string ChannelConfigVersion = "https://ersatztv.org/channel/version/0.1.1"; public async Task ToNext( ChannelViewModel channel, FFmpegProfileViewModel ffmpegProfile, + Option targetFramerate, CancellationToken cancellationToken) { var ffmpeg = new Ffmpeg @@ -147,6 +150,11 @@ public async Task ToNext( } }; + foreach (string frameRate in targetFramerate.Bind(ToNextFrameRate)) + { + videoNormalization.FrameRate = frameRate; + } + var subtitleNormalization = new Subtitle { Mode = channel.NextEngineTextSubtitleMode switch @@ -179,4 +187,45 @@ public async Task ToNext( } }; } + + private const int MaxNextFrameRate = 240; + + // reject decimals + public static Option ToNextFrameRate(FrameRate frameRate) + { + string value = frameRate.RFrameRate.Trim(); + + string[] parts = value.Split('/'); + if (parts.Length <= 2 && parts.All(p => p.Length > 0 && p.All(char.IsAsciiDigit))) + { + if (!long.TryParse(parts[0], out long num) || + !long.TryParse(parts.Length == 2 ? parts[1] : "1", out long den) || + num == 0 || den == 0 || num < den || num > den * MaxNextFrameRate) + { + return Option.None; + } + + return value; + } + + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double rate) || + rate < 1 || rate > MaxNextFrameRate) + { + return Option.None; + } + + double integer = Math.Round(rate); + if (Math.Abs(rate - integer) < 0.001) + { + return ((long)integer).ToString(CultureInfo.InvariantCulture); + } + + double ntsc = Math.Round(rate * 1.001); + if (Math.Abs(rate * 1.001 - ntsc) < 0.005) + { + return $"{(long)ntsc * 1000}/1001"; + } + + return $"{(long)Math.Round(rate * 1000)}/1000"; + } } diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs index 0e4088d78..d4be7f463 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs @@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Next.Config; +using ErsatzTV.FFmpeg; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -68,9 +69,9 @@ private async Task> CreateWorker( Option idleTimeout = Option.None; - // Option targetFramerate = await mediator.Send( - // new GetChannelFramerate(request.ChannelNumber), - // cancellationToken); + Option targetFramerate = await mediator.Send( + new GetChannelFramerate(request.ChannelNumber), + cancellationToken); Option maybeChannel = await mediator.Send(new GetChannelByNumber(request.ChannelNumber), cancellationToken); @@ -105,7 +106,11 @@ private async Task> CreateWorker( PrepareTranscodeFolder(request.ChannelNumber); - ChannelConfig config = await channelConfigConverter.ToNext(channel, ffmpegProfile, cancellationToken); + ChannelConfig config = await channelConfigConverter.ToNext( + channel, + ffmpegProfile, + targetFramerate, + cancellationToken); NextSessionWorker worker = new NextSessionWorker( channelBinary, diff --git a/ErsatzTV.Application/Streaming/IChannelConfigConverter.cs b/ErsatzTV.Application/Streaming/IChannelConfigConverter.cs index 95071ddd9..834026de9 100644 --- a/ErsatzTV.Application/Streaming/IChannelConfigConverter.cs +++ b/ErsatzTV.Application/Streaming/IChannelConfigConverter.cs @@ -1,5 +1,6 @@ using ErsatzTV.Application.Channels; using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.FFmpeg; namespace ErsatzTV.Application.Streaming; @@ -8,5 +9,6 @@ public interface IChannelConfigConverter Task ToNext( ChannelViewModel channel, FFmpegProfileViewModel ffmpegProfile, + Option targetFramerate, CancellationToken cancellationToken); } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index feff65bfc..c8ff638be 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -366,6 +366,7 @@ private async Task> GetNextProcess( ChannelConfig config = await channelConfigConverter.ToNext( Channels.Mapper.ProjectToViewModel(channel, playoutCount: 0), FFmpegProfiles.Mapper.ProjectToViewModel(ffmpegProfile), + Option.None, cancellationToken); config.Playout.VirtualStart = start.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK", CultureInfo.InvariantCulture); diff --git a/ErsatzTV.Core/Next/Config/ChannelConfig.cs b/ErsatzTV.Core/Next/Config/ChannelConfig.cs index b26f23d28..a6d531d3b 100644 --- a/ErsatzTV.Core/Next/Config/ChannelConfig.cs +++ b/ErsatzTV.Core/Next/Config/ChannelConfig.cs @@ -204,6 +204,14 @@ public partial class Video [JsonPropertyName("format")] public VideoFormat? Format { get; set; } + /// + /// Output frame rate, `N` or `N/D` (e.g. `25`, `30000/1001`). + /// Frames are dropped or repeated to match it. + /// In `copy` mode, only items at this rate are copied. Unset keeps the source rate. + /// + [JsonPropertyName("frame_rate")] + public string? FrameRate { get; set; } + [JsonPropertyName("height")] public long? Height { get; set; } diff --git a/ErsatzTV.Tests/Streaming/ChannelConfigConverterTests.cs b/ErsatzTV.Tests/Streaming/ChannelConfigConverterTests.cs index 6680fb85e..423c9b440 100644 --- a/ErsatzTV.Tests/Streaming/ChannelConfigConverterTests.cs +++ b/ErsatzTV.Tests/Streaming/ChannelConfigConverterTests.cs @@ -1,4 +1,3 @@ -using System.IO.Abstractions; using ErsatzTV.Application.Channels; using ErsatzTV.Application.FFmpegProfiles; using ErsatzTV.Application.Streaming; @@ -6,6 +5,7 @@ using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Next.Config; +using ErsatzTV.FFmpeg; using LanguageExt; using NUnit.Framework; using Testably.Abstractions; @@ -131,9 +131,52 @@ public async Task Transcode_profile_should_transcode() config.Normalization.Video.Mode.ShouldBe(StreamMode.Transcode); } + [Test] + public async Task No_target_framerate_should_pass_source_rate_through() + { + ChannelConfig config = await Convert(Profile); + + config.Normalization.Video.FrameRate.ShouldBeNull(); + } + + [Test] + public async Task Target_framerate_should_set_frame_rate() + { + ChannelConfig config = await Convert(Profile, new FrameRate("24000/1001")); + + config.Normalization.Video.FrameRate.ShouldBe("24000/1001"); + } + + [TestCase("24000/1001", "24000/1001")] + [TestCase("25/1", "25/1")] + [TestCase("30", "30")] + [TestCase("23.976", "24000/1001")] + [TestCase("23.98", "24000/1001")] + [TestCase("29.97", "30000/1001")] + [TestCase("59.94", "60000/1001")] + [TestCase("25.00", "25")] + [TestCase("12.5", "12500/1000")] + public void ToNextFrameRate_should_convert_to_rational(string input, string expected) => + ChannelConfigConverter.ToNextFrameRate(new FrameRate(input)).ShouldBe(Option.Some(expected)); + + [TestCase("")] + [TestCase("0/0")] + [TestCase("30/0")] + [TestCase("1/2")] + [TestCase("0.5")] + [TestCase("241")] + [TestCase("1000/1")] + [TestCase("abc")] + [TestCase("-30")] + public void ToNextFrameRate_should_reject_unusable_rates(string input) => + ChannelConfigConverter.ToNextFrameRate(new FrameRate(input)).IsNone.ShouldBeTrue(); + private static Task Convert(FFmpegProfileViewModel profile) => + Convert(profile, Option.None); + + private static Task Convert(FFmpegProfileViewModel profile, Option targetFramerate) => new ChannelConfigConverter(new EmptyConfigElementRepository(), new RealFileSystem()) - .ToNext(Channel, profile, CancellationToken.None); + .ToNext(Channel, profile, targetFramerate, CancellationToken.None); private sealed class EmptyConfigElementRepository : IConfigElementRepository {