From 54606c76f977b77109805e21eeaf6dde65931075 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:20:09 -0600 Subject: [PATCH] framerate improvements (#2692) * framerate improvements * fixes --- CHANGELOG.md | 7 ++ .../Channels/Queries/GetChannelFramerate.cs | 6 +- .../Queries/GetChannelFramerateHandler.cs | 83 +++++++------------ .../Commands/StartFFmpegSessionHandler.cs | 5 +- .../Streaming/HlsSessionWorker.cs | 5 +- .../GetPlayoutItemProcessByChannelNumber.cs | 3 +- ...layoutItemProcessByChannelNumberHandler.cs | 4 - .../PrepareTroubleshootingPlaybackHandler.cs | 4 +- .../FFmpeg/FFmpegLibraryProcessService.cs | 11 ++- .../FFmpeg/FFmpegPlaybackSettings.cs | 3 +- .../FFmpegPlaybackSettingsCalculator.cs | 4 +- ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs | 2 +- .../FFmpeg/IFFmpegProcessService.cs | 2 +- .../Streaming/GraphicsEngineContext.cs | 3 +- .../Metadata/FFmpegProfileTemplateDataKey.cs | 2 + ErsatzTV.FFmpeg.Tests/MediaStreamTests.cs | 10 +-- .../PipelineBuilderBaseTests.cs | 18 ++-- ErsatzTV.FFmpeg/Filter/FrameRateFilter.cs | 6 +- ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs | 6 +- ErsatzTV.FFmpeg/FrameRate.cs | 30 +++++++ ErsatzTV.FFmpeg/FrameState.cs | 2 +- ErsatzTV.FFmpeg/InputFile.cs | 2 +- .../InputOption/RawVideoInputOption.cs | 4 +- ErsatzTV.FFmpeg/MediaStream.cs | 2 +- .../OutputFormat/OutputFormatHls.cs | 39 ++------- .../OutputOption/FrameRateOutputOption.cs | 22 ++--- .../Pipeline/PipelineBuilderBase.cs | 21 +++-- .../Pipeline/QsvPipelineBuilder.cs | 12 ++- .../Graphics/GraphicsElementLoader.cs | 2 + .../Streaming/Graphics/GraphicsEngine.cs | 4 +- .../Graphics/Motion/MotionElement.cs | 2 +- ErsatzTV/Controllers/InternalController.cs | 3 +- ErsatzTV/Controllers/IptvController.cs | 3 +- 33 files changed, 164 insertions(+), 168 deletions(-) create mode 100644 ErsatzTV.FFmpeg/FrameRate.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f23b2b8..c10b92528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields - Remote streams can now have thumbnails (same name as yaml file but with image extension) - This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds +- Add framerate template data to graphics engine + - `RFrameRate` - the real content framerate (or channel normalized framerate) as reported by ffmpeg, e.g. `30000/1001` + - `FrameRate` - the decimal representation of `RFrameRate`, e.g. `29.97002997` ### Fixed - Fix startup on systems unsupported by NvEncSharp @@ -29,6 +32,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - A warning will be logged when this scenario is detected - AMD VAAPI: work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080 - Optimize graphics engine to generate element frames in parallel and to eliminate redundant frame copies +- Match graphics engine framerate with source content (or channel normalized) framerate + +### Changed +- No longer round framerate to nearest integer when normalizing framerate ## [25.9.0] - 2025-11-29 ### Added diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelFramerate.cs b/ErsatzTV.Application/Channels/Queries/GetChannelFramerate.cs index 7d907e277..331c5f7bf 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelFramerate.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelFramerate.cs @@ -1,3 +1,5 @@ -namespace ErsatzTV.Application.Channels; +using ErsatzTV.FFmpeg; -public record GetChannelFramerate(string ChannelNumber) : IRequest>; +namespace ErsatzTV.Application.Channels; + +public record GetChannelFramerate(string ChannelNumber) : IRequest>; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelFramerateHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelFramerateHandler.cs index 69a8954a1..0d93398cb 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelFramerateHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelFramerateHandler.cs @@ -1,29 +1,22 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; +using ErsatzTV.FFmpeg; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Channels; -public class GetChannelFramerateHandler : IRequestHandler> +public class GetChannelFramerateHandler( + IDbContextFactory dbContextFactory, + ILogger logger) + : IRequestHandler> { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - - public GetChannelFramerateHandler( - IDbContextFactory dbContextFactory, - ILogger logger) - { - _dbContextFactory = dbContextFactory; - _logger = logger; - } - - public async Task> Handle(GetChannelFramerate request, CancellationToken cancellationToken) + public async Task> Handle(GetChannelFramerate request, CancellationToken cancellationToken) { try { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); FFmpegProfile ffmpegProfile = await dbContext.Channels .AsNoTracking() @@ -34,11 +27,11 @@ public class GetChannelFramerateHandler : IRequestHandler.None; + return Option.None; } // TODO: expand to check everything in collection rather than what's scheduled? - _logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber); + logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber); List playouts = await dbContext.Playouts .AsNoTracking() @@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler p.Items.Map(i => i.MediaItem.GetHeadVersion())) .Flatten() - .Map(mv => mv.RFrameRate) + .Map(mv => new FrameRate(mv.RFrameRate)) .ToList(); var distinct = frameRates.Distinct().ToList(); if (distinct.Count > 1) { // TODO: something more intelligent than minimum framerate? - int result = frameRates.Map(ParseFrameRate).Min(); - if (result < 24) + var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList(); + if (validFrameRates.Count > 0) { - _logger.LogInformation( - "Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}", + FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate); + logger.LogInformation( + "Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}", request.ChannelNumber, - distinct, - 24, - result); - - return 24; + distinct.Map(fr => fr.RFrameRate), + result.RFrameRate); + return result; } - _logger.LogInformation( - "Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}", + FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate); + logger.LogInformation( + "Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}", request.ChannelNumber, - distinct, - result); - return result; + distinct.Map(fr => fr.RFrameRate), + FrameRate.DefaultFrameRate.RFrameRate, + minFrameRate.RFrameRate); + + return FrameRate.DefaultFrameRate; } if (distinct.Count != 0) { - _logger.LogInformation( + logger.LogInformation( "All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize", request.ChannelNumber, - distinct[0]); + distinct[0].RFrameRate); } else { - _logger.LogInformation( + logger.LogInformation( "No content on channel {ChannelNumber} has frame rate information; will not normalize", request.ChannelNumber); } } catch (Exception ex) { - _logger.LogWarning( + logger.LogWarning( ex, "Unexpected error checking frame rates on channel {ChannelNumber}", request.ChannelNumber); @@ -120,22 +115,4 @@ public class GetChannelFramerateHandler : IRequestHandler(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken) .Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1))); - Option targetFramerate = await _mediator.Send( + Option targetFramerate = await _mediator.Send( new GetChannelFramerate(request.ChannelNumber), cancellationToken); @@ -122,7 +123,7 @@ public class StartFFmpegSessionHandler : IRequestHandler targetFramerate) => + private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option targetFramerate) => request.Mode switch { _ => new HlsSessionWorker( diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index ea943a9f3..eb3182450 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -16,6 +16,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Streaming; +using ErsatzTV.FFmpeg; using ErsatzTV.FFmpeg.OutputFormat; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -39,7 +40,7 @@ public class HlsSessionWorker : IHlsSessionWorker private readonly IMediator _mediator; private readonly SemaphoreSlim _slim = new(1, 1); private readonly Lock _sync = new(); - private readonly Option _targetFramerate; + private readonly Option _targetFramerate; private CancellationTokenSource _cancellationTokenSource; private string _channelNumber; private DateTimeOffset _channelStart; @@ -65,7 +66,7 @@ public class HlsSessionWorker : IHlsSessionWorker IFileSystem fileSystem, ILocalFileSystem localFileSystem, ILogger logger, - Option targetFramerate) + Option targetFramerate) { _serviceScope = serviceScopeFactory.CreateScope(); _mediator = _serviceScope.ServiceProvider.GetRequiredService(); diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs index 9df8770dc..dfbdb5029 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.FFmpeg; namespace ErsatzTV.Application.Streaming; @@ -10,7 +11,7 @@ public record GetPlayoutItemProcessByChannelNumber( bool HlsRealtime, DateTimeOffset ChannelStart, TimeSpan PtsOffset, - Option TargetFramerate, + Option TargetFramerate, bool IsTroubleshooting, Option FFmpegProfileId) : FFmpegProcessRequest( ChannelNumber, diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 2e042f889..881aa5371 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -12,7 +12,6 @@ using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Jellyfin; -using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Streaming; @@ -34,7 +33,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< private readonly IFFmpegProcessService _ffmpegProcessService; private readonly IFileSystem _fileSystem; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; - private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator; @@ -50,7 +48,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< IDbContextFactory dbContextFactory, IFFmpegProcessService ffmpegProcessService, IFileSystem fileSystem, - ILocalFileSystem localFileSystem, IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider, IPlexPathReplacementService plexPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService, @@ -68,7 +65,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< { _ffmpegProcessService = ffmpegProcessService; _fileSystem = fileSystem; - _localFileSystem = localFileSystem; _externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider; _plexPathReplacementService = plexPathReplacementService; _jellyfinPathReplacementService = jellyfinPathReplacementService; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 48a824da7..7f94f29a8 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -87,7 +87,7 @@ public class PrepareTroubleshootingPlaybackHandler( HlsRealtime: false, start, TimeSpan.Zero, - TargetFramerate: Option.None, + TargetFramerate: Option.None, IsTroubleshooting: true, request.FFmpegProfileId), cancellationToken); @@ -318,7 +318,7 @@ public class PrepareTroubleshootingPlaybackHandler( inPoint, channelStartTime: DateTimeOffset.Now, TimeSpan.Zero, - Option.None, + Option.None, FileSystemLayout.TranscodeTroubleshootingFolder, _ => { }, canProxy: true, diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 53ebee4f1..fd7d782d6 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -98,7 +98,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService TimeSpan inPoint, DateTimeOffset channelStartTime, TimeSpan ptsOffset, - Option targetFramerate, + Option targetFramerate, Option customReportsFolder, Action pipelineAction, bool canProxy, @@ -245,7 +245,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService new FrameSize(videoVersion.MediaVersion.Width, videoVersion.MediaVersion.Height), videoVersion.MediaVersion.SampleAspectRatio, videoVersion.MediaVersion.DisplayAspectRatio, - videoVersion.MediaVersion.RFrameRate, + new FrameRate(videoVersion.MediaVersion.RFrameRate), videoPath != audioPath, // still image when paths are different scanKind); @@ -381,7 +381,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService new FrameSize(1, 1), string.Empty, string.Empty, - Option.None, + Option.None, !await IsWatermarkAnimated(ffprobePath, wm.ImagePath), ScanKind.Progressive) ]; @@ -520,6 +520,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService { FrameSize targetSize = await desiredState.CroppedSize.IfNoneAsync(desiredState.ScaledSize); + FrameRate frameRate = await playbackSettings.FrameRate + .IfNoneAsync(new FrameRate(videoVersion.MediaVersion.RFrameRate)); + var context = new GraphicsEngineContext( channel.Number, audioVersion.MediaItem, @@ -527,7 +530,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService TemplateVariables: [], new Resolution { Width = targetSize.Width, Height = targetSize.Height }, channel.FFmpegProfile.Resolution, - await playbackSettings.FrameRate.IfNoneAsync(24), + frameRate, channelStartTime, start, await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero), diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs index 0c44926f7..2f427d614 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.FFmpeg; using ErsatzTV.FFmpeg.Format; namespace ErsatzTV.Core.FFmpeg; @@ -28,5 +29,5 @@ public class FFmpegPlaybackSettings public bool Deinterlace { get; set; } public Option VideoTrackTimeScale { get; set; } public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; } - public Option FrameRate { get; set; } + public Option FrameRate { get; set; } } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index 4fefed49c..ef20e297a 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -52,7 +52,7 @@ public static class FFmpegPlaybackSettingsCalculator TimeSpan inPoint, bool hlsRealtime, StreamInputKind streamInputKind, - Option targetFramerate) + Option targetFramerate) { var result = new FFmpegPlaybackSettings { @@ -195,7 +195,7 @@ public static class FFmpegPlaybackSettingsCalculator _ => true }, VideoTrackTimeScale = 90000, - FrameRate = 24 + FrameRate = FrameRate.DefaultFrameRate }; private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version, string sampleAspectRatio) => diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 02d36f4fa..42ced7862 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -86,7 +86,7 @@ public class FFmpegProcessService TimeSpan.Zero, false, StreamInputKind.Vod, - Option.None); + Option.None); scalePlaybackSettings.AudioChannels = Option.None; diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index b9a8273ca..79f3ac935 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -39,7 +39,7 @@ public interface IFFmpegProcessService TimeSpan inPoint, DateTimeOffset channelStartTime, TimeSpan ptsOffset, - Option targetFramerate, + Option targetFramerate, Option customReportsFolder, Action pipelineAction, bool canProxy, diff --git a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs index 5af9612d0..5035fd2cf 100644 --- a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs +++ b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs @@ -1,6 +1,7 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Graphics; +using ErsatzTV.FFmpeg; namespace ErsatzTV.Core.Interfaces.Streaming; @@ -11,7 +12,7 @@ public record GraphicsEngineContext( Dictionary TemplateVariables, Resolution SquarePixelFrameSize, Resolution FrameSize, - int FrameRate, + FrameRate FrameRate, DateTimeOffset ChannelStartTime, DateTimeOffset ContentStartTime, TimeSpan Seek, diff --git a/ErsatzTV.Core/Metadata/FFmpegProfileTemplateDataKey.cs b/ErsatzTV.Core/Metadata/FFmpegProfileTemplateDataKey.cs index 90ae45f97..cbf2eb216 100644 --- a/ErsatzTV.Core/Metadata/FFmpegProfileTemplateDataKey.cs +++ b/ErsatzTV.Core/Metadata/FFmpegProfileTemplateDataKey.cs @@ -4,4 +4,6 @@ public static class FFmpegProfileTemplateDataKey { public static readonly string Resolution = "Resolution"; public static readonly string ScaledResolution = "ScaledResolution"; + public static readonly string RFrameRate = "RFrameRate"; + public static readonly string FrameRate = "FrameRate"; } diff --git a/ErsatzTV.FFmpeg.Tests/MediaStreamTests.cs b/ErsatzTV.FFmpeg.Tests/MediaStreamTests.cs index 9e732e25d..3af349f44 100644 --- a/ErsatzTV.FFmpeg.Tests/MediaStreamTests.cs +++ b/ErsatzTV.FFmpeg.Tests/MediaStreamTests.cs @@ -21,7 +21,7 @@ public class MediaStreamTests FrameSize.Unknown, "0:0", "4:3", - Option.None, + Option.None, false, ScanKind.Progressive); @@ -41,7 +41,7 @@ public class MediaStreamTests FrameSize.Unknown, "0:0", "4:3", - Option.None, + Option.None, false, ScanKind.Progressive); @@ -61,7 +61,7 @@ public class MediaStreamTests FrameSize.Unknown, "1:1", "16:9", - Option.None, + Option.None, false, ScanKind.Progressive); @@ -81,7 +81,7 @@ public class MediaStreamTests FrameSize.Unknown, "32:27", "16:9", - Option.None, + Option.None, false, ScanKind.Progressive); @@ -101,7 +101,7 @@ public class MediaStreamTests FrameSize.Unknown, "1.5:3.5", "16:9", - Option.None, + Option.None, false, ScanKind.Progressive); diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index c10351fdf..3adea73f4 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -38,7 +38,7 @@ public class PipelineBuilderBaseTests new FrameSize(1920, 1080), "1:1", "16:9", - "24", + FrameRate.DefaultFrameRate, false, ScanKind.Progressive) }); @@ -67,7 +67,7 @@ public class PipelineBuilderBaseTests new FrameSize(1920, 1080), Option.None, false, - Option.None, + Option.None, 2000, 4000, 90_000, @@ -138,7 +138,7 @@ public class PipelineBuilderBaseTests new FrameSize(1920, 1080), "1:1", "16:9", - "24", + FrameRate.DefaultFrameRate, false, ScanKind.Progressive) }); @@ -167,7 +167,7 @@ public class PipelineBuilderBaseTests new FrameSize(1920, 1080), Option.None, false, - Option.None, + Option.None, 2000, 4000, 90_000, @@ -296,7 +296,7 @@ public class PipelineBuilderBaseTests new FrameSize(1920, 1080), "1:1", "16:9", - "24", + FrameRate.DefaultFrameRate, false, ScanKind.Interlaced) }); @@ -325,7 +325,7 @@ public class PipelineBuilderBaseTests new FrameSize(1920, 1080), Option.None, false, - Option.None, + Option.None, 2000, 4000, 90_000, @@ -400,7 +400,7 @@ public class PipelineBuilderBaseTests new FrameSize(1920, 1080), "1:1", "16:9", - "24", + FrameRate.DefaultFrameRate, false, ScanKind.Progressive) }); @@ -419,7 +419,7 @@ public class PipelineBuilderBaseTests new FrameSize(1920, 1080), Option.None, false, - Option.None, + Option.None, 2000, 4000, 90_000, @@ -494,7 +494,7 @@ public class PipelineBuilderBaseTests FrameSize.Unknown, string.Empty, string.Empty, - Option.None, + Option.None, true, ScanKind.Progressive) }); diff --git a/ErsatzTV.FFmpeg/Filter/FrameRateFilter.cs b/ErsatzTV.FFmpeg/Filter/FrameRateFilter.cs index 1ee72aac8..1cdc05865 100644 --- a/ErsatzTV.FFmpeg/Filter/FrameRateFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/FrameRateFilter.cs @@ -3,9 +3,9 @@ public class FrameRateFilter : BaseFilter { private readonly FrameState _currentState; - private readonly int _frameRate; + private readonly FrameRate _frameRate; - public FrameRateFilter(FrameState currentState, int frameRate) + public FrameRateFilter(FrameState currentState, FrameRate frameRate) { _currentState = currentState; _frameRate = frameRate; @@ -15,7 +15,7 @@ public class FrameRateFilter : BaseFilter { get { - var frameRate = $"framerate=fps={_frameRate}:flags=-scd"; + var frameRate = $"framerate=fps={_frameRate.RFrameRate}:flags=-scd"; string pixelFormat = _currentState.PixelFormat.Match(pf => pf.FFmpegName, () => string.Empty); if (_currentState.FrameDataLocation == FrameDataLocation.Hardware) diff --git a/ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs b/ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs index a6053e119..a9f050a4e 100644 --- a/ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs @@ -1,8 +1,8 @@ namespace ErsatzTV.FFmpeg.Filter; -public class ResetPtsFilter(string fps) : BaseFilter +public class ResetPtsFilter(FrameRate frameRate) : BaseFilter { - public override string Filter => $"setpts=PTS-STARTPTS,fps={fps}"; + public override string Filter => $"setpts=PTS-STARTPTS,fps={frameRate.RFrameRate}"; - public override FrameState NextState(FrameState currentState) => currentState; + public override FrameState NextState(FrameState currentState) => currentState with { FrameRate = frameRate }; } diff --git a/ErsatzTV.FFmpeg/FrameRate.cs b/ErsatzTV.FFmpeg/FrameRate.cs new file mode 100644 index 000000000..d1b6c35d0 --- /dev/null +++ b/ErsatzTV.FFmpeg/FrameRate.cs @@ -0,0 +1,30 @@ +namespace ErsatzTV.FFmpeg; + +public record FrameRate(string? FrameRateString) +{ + public double ParsedFrameRate { get; init; } = ParseFrameRate(FrameRateString, 24.0); + + public string RFrameRate { get; init; } = FrameRateString ?? "24"; + + public static FrameRate DefaultFrameRate => new("24"); + + public static double ParseFrameRate(string? rFrameRate, double defaultFrameRate) + { + double frameRate = defaultFrameRate; + + if (double.TryParse(rFrameRate, out double value)) + { + frameRate = value; + } + else + { + string[] split = (rFrameRate ?? string.Empty).Split("/"); + if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right) && right != 0) + { + frameRate = left / (double)right; + } + } + + return frameRate; + } +} diff --git a/ErsatzTV.FFmpeg/FrameState.cs b/ErsatzTV.FFmpeg/FrameState.cs index 50238c43c..897f29154 100644 --- a/ErsatzTV.FFmpeg/FrameState.cs +++ b/ErsatzTV.FFmpeg/FrameState.cs @@ -14,7 +14,7 @@ public record FrameState( FrameSize PaddedSize, Option CroppedSize, bool IsAnamorphic, - Option FrameRate, + Option FrameRate, Option VideoBitrate, Option VideoBufferSize, Option VideoTrackTimeScale, diff --git a/ErsatzTV.FFmpeg/InputFile.cs b/ErsatzTV.FFmpeg/InputFile.cs index 2792c48a6..930700d4b 100644 --- a/ErsatzTV.FFmpeg/InputFile.cs +++ b/ErsatzTV.FFmpeg/InputFile.cs @@ -23,7 +23,7 @@ public record ConcatInputFile(string Url, FrameSize Resolution) : InputFile( Resolution, string.Empty, string.Empty, - Option.None, + Option.None, false, ScanKind.Unknown) }) diff --git a/ErsatzTV.FFmpeg/InputOption/RawVideoInputOption.cs b/ErsatzTV.FFmpeg/InputOption/RawVideoInputOption.cs index 7d7223a05..affdd11f5 100644 --- a/ErsatzTV.FFmpeg/InputOption/RawVideoInputOption.cs +++ b/ErsatzTV.FFmpeg/InputOption/RawVideoInputOption.cs @@ -2,7 +2,7 @@ namespace ErsatzTV.FFmpeg.InputOption; -public class RawVideoInputOption(string pixelFormat, FrameSize frameSize, int frameRate) : IInputOption +public class RawVideoInputOption(string pixelFormat, FrameSize frameSize, FrameRate frameRate) : IInputOption { public EnvironmentVariable[] EnvironmentVariables => []; public string[] GlobalOptions => []; @@ -13,7 +13,7 @@ public class RawVideoInputOption(string pixelFormat, FrameSize frameSize, int fr "-vcodec", "rawvideo", "-pix_fmt", pixelFormat, "-s", $"{frameSize.Width}x{frameSize.Height}", - "-r", $"{frameRate}" + "-r", $"{frameRate.RFrameRate}" ]; public string[] FilterOptions => []; diff --git a/ErsatzTV.FFmpeg/MediaStream.cs b/ErsatzTV.FFmpeg/MediaStream.cs index a64f2aa63..224c8b7ac 100644 --- a/ErsatzTV.FFmpeg/MediaStream.cs +++ b/ErsatzTV.FFmpeg/MediaStream.cs @@ -23,7 +23,7 @@ public record VideoStream( FrameSize FrameSize, string MaybeSampleAspectRatio, string DisplayAspectRatio, - Option FrameRate, + Option FrameRate, bool StillImage, ScanKind ScanKind) : MediaStream( Index, diff --git a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs index 9f2df1360..396a8b5ed 100644 --- a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs +++ b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs @@ -9,7 +9,7 @@ public class OutputFormatHls : IPipelineStep private readonly FrameState _desiredState; private readonly bool _isFirstTranscode; private readonly bool _isTroubleshooting; - private readonly Option _mediaFrameRate; + private readonly Option _mediaFrameRate; private readonly OutputFormatKind _outputFormat; private readonly Option _segmentOptions; private readonly bool _oneSecondGop; @@ -19,7 +19,7 @@ public class OutputFormatHls : IPipelineStep public OutputFormatHls( FrameState desiredState, - Option mediaFrameRate, + Option mediaFrameRate, OutputFormatKind outputFormat, Option segmentOptions, string segmentTemplate, @@ -50,14 +50,16 @@ public class OutputFormatHls : IPipelineStep { get { - int frameRate = _desiredState.FrameRate.IfNone(GetFrameRateFromMedia); + FrameRate frameRate = _desiredState.FrameRate.IfNone(_mediaFrameRate.IfNone(FrameRate.DefaultFrameRate)); - int gop = _oneSecondGop ? frameRate : frameRate * SegmentSeconds; + int gop = _oneSecondGop + ? (int)Math.Round(frameRate.ParsedFrameRate) + : (int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds); List result = [ "-g", $"{gop}", - "-keyint_min", $"{frameRate * SegmentSeconds}", + "-keyint_min", $"{(int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds)}", "-force_key_frames", $"expr:gte(t,n_forced*{SegmentSeconds})", "-f", "hls", "-hls_time", $"{SegmentSeconds}", @@ -127,31 +129,4 @@ public class OutputFormatHls : IPipelineStep } public FrameState NextState(FrameState currentState) => currentState; - - private int GetFrameRateFromMedia() - { - var frameRate = 24; - - foreach (string rFrameRate in _mediaFrameRate) - { - if (double.TryParse(rFrameRate, out double value)) - { - frameRate = (int)Math.Round(value); - } - else if (!int.TryParse(rFrameRate, out int fr)) - { - string[] split = (rFrameRate ?? string.Empty).Split("/"); - if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right)) - { - frameRate = (int)Math.Round(left / (double)right); - } - else - { - frameRate = 24; - } - } - } - - return frameRate; - } } diff --git a/ErsatzTV.FFmpeg/OutputOption/FrameRateOutputOption.cs b/ErsatzTV.FFmpeg/OutputOption/FrameRateOutputOption.cs index ad2d375f8..2d08e494a 100644 --- a/ErsatzTV.FFmpeg/OutputOption/FrameRateOutputOption.cs +++ b/ErsatzTV.FFmpeg/OutputOption/FrameRateOutputOption.cs @@ -1,24 +1,18 @@ -using System.Globalization; -using ErsatzTV.FFmpeg.Environment; +using ErsatzTV.FFmpeg.Environment; namespace ErsatzTV.FFmpeg.OutputOption; -public class FrameRateOutputOption : IPipelineStep +public class FrameRateOutputOption(FrameRate frameRate) : IPipelineStep { - private readonly int _frameRate; + public EnvironmentVariable[] EnvironmentVariables => []; + public string[] GlobalOptions => []; + public string[] InputOptions(InputFile inputFile) => []; + public string[] FilterOptions => []; - public FrameRateOutputOption(int frameRate) => _frameRate = frameRate; - - public EnvironmentVariable[] EnvironmentVariables => Array.Empty(); - public string[] GlobalOptions => Array.Empty(); - public string[] InputOptions(InputFile inputFile) => Array.Empty(); - public string[] FilterOptions => Array.Empty(); - - public string[] OutputOptions => new[] - { "-r", _frameRate.ToString(CultureInfo.InvariantCulture), "-vsync", "cfr" }; + public string[] OutputOptions => ["-r", frameRate.RFrameRate, "-vsync", "cfr"]; public FrameState NextState(FrameState currentState) => currentState with { - FrameRate = _frameRate + FrameRate = frameRate }; } diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index c4ad848d3..9eb3af6cd 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -229,14 +229,6 @@ public abstract class PipelineBuilderBase : IPipelineBuilder concatInputFile.AddOption(new InfiniteLoopInputOption(HardwareAccelerationMode.None)); } - foreach (GraphicsEngineInput graphicsEngineInput in _graphicsEngineInput) - { - var targetSize = desiredState.CroppedSize.IfNone(desiredState.PaddedSize); - - graphicsEngineInput.AddOption( - new RawVideoInputOption(PixelFormat.BGRA, targetSize, desiredState.FrameRate.IfNone(24))); - } - Debug.Assert(_videoInputFile.IsSome, "Pipeline builder requires exactly one video input file"); VideoInputFile videoInputFile = _videoInputFile.Head(); @@ -244,6 +236,17 @@ public abstract class PipelineBuilderBase : IPipelineBuilder Debug.Assert(allVideoStreams.Count == 1, "Pipeline builder requires exactly one video stream"); VideoStream videoStream = allVideoStreams.Head(); + foreach (GraphicsEngineInput graphicsEngineInput in _graphicsEngineInput) + { + var targetSize = desiredState.CroppedSize.IfNone(desiredState.PaddedSize); + + graphicsEngineInput.AddOption( + new RawVideoInputOption( + PixelFormat.BGRA, + targetSize, + desiredState.FrameRate.IfNone(videoStream.FrameRate.IfNone(FrameRate.DefaultFrameRate)))); + } + var context = new PipelineContext( _hardwareAccelerationMode, _graphicsEngineInput.IsSome, @@ -754,7 +757,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder return; } - foreach (int desiredFrameRate in desiredState.FrameRate) + foreach (FrameRate desiredFrameRate in desiredState.FrameRate) { pipelineSteps.Add(new FrameRateOutputOption(desiredFrameRate)); } diff --git a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs index 343eb356b..bc5595545 100644 --- a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs @@ -1,4 +1,3 @@ -using System.Globalization; using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Decoder; using ErsatzTV.FFmpeg.Decoder.Qsv; @@ -203,15 +202,14 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder } // use normalized fps, or source fps - string fps = desiredState.FrameRate.Match( - r => r.ToString(NumberFormatInfo.InvariantInfo), - () => videoStream.FrameRate.IfNone("24")); - videoInputFile.FilterSteps.Add(new ResetPtsFilter(fps)); + FrameRate frameRate = + desiredState.FrameRate.IfNone(videoStream.FrameRate.IfNone(FrameRate.DefaultFrameRate)); + videoInputFile.FilterSteps.Add(new ResetPtsFilter(frameRate)); // since fps will set frame rate, remove the output option - foreach (var frameRate in pipelineSteps.OfType().HeadOrNone()) + foreach (var fr in pipelineSteps.OfType().HeadOrNone()) { - pipelineSteps.Remove(frameRate); + pipelineSteps.Remove(fr); } currentState = SetSubtitle( diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs index 26ecec964..cbfd633b6 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs @@ -246,6 +246,8 @@ public partial class GraphicsElementLoader( { [FFmpegProfileTemplateDataKey.Resolution] = context.FrameSize, [FFmpegProfileTemplateDataKey.ScaledResolution] = context.SquarePixelFrameSize, + [FFmpegProfileTemplateDataKey.RFrameRate] = context.FrameRate.RFrameRate, + [FFmpegProfileTemplateDataKey.FrameRate] = context.FrameRate.ParsedFrameRate, [MediaItemTemplateDataKey.StreamSeek] = context.Seek, [MediaItemTemplateDataKey.Start] = context.ContentStartTime, [MediaItemTemplateDataKey.Stop] = context.ContentStartTime + context.Duration diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs index b470a6f30..deb3a6a35 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs @@ -87,7 +87,7 @@ public class GraphicsEngine( await Task.WhenAll(elements.Select(e => e.InitializeAsync(context, cancellationToken))); long frameCount = 0; - var totalFrames = (long)(context.Duration.TotalSeconds * context.FrameRate); + var totalFrames = (long)(context.Duration.TotalSeconds * context.FrameRate.ParsedFrameRate); int width = context.FrameSize.Width; int height = context.FrameSize.Height; @@ -107,7 +107,7 @@ public class GraphicsEngine( while (!cancellationToken.IsCancellationRequested && frameCount < totalFrames) { // seconds since this specific stream started - double streamTimeSeconds = (double)frameCount / context.FrameRate; + double streamTimeSeconds = frameCount / context.FrameRate.ParsedFrameRate; var streamTime = TimeSpan.FromSeconds(streamTimeSeconds); // `content_seconds` - the total number of seconds the frame is into the content diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs index 265573b3b..2d76fba7a 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs @@ -164,7 +164,7 @@ public class MotionElement( "-i", motionElement.VideoPath, ]); - var videoFilter = $"fps={context.FrameRate}"; + var videoFilter = $"fps={context.FrameRate.RFrameRate}"; if (motionElement.Scale) { videoFilter += $",scale={targetSize.Width}:{targetSize.Height}"; diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index 42fd5fa00..66669123b 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -13,6 +13,7 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Extensions; +using ErsatzTV.FFmpeg; using Flurl; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -259,7 +260,7 @@ public class InternalController : StreamingControllerBase true, DateTimeOffset.Now, TimeSpan.Zero, - Option.None, + Option.None, IsTroubleshooting: false, Option.None); diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index b3c6a6a64..d10f37de3 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -13,6 +13,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Iptv; using ErsatzTV.Extensions; +using ErsatzTV.FFmpeg; using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -346,7 +347,7 @@ public class IptvController : StreamingControllerBase true, DateTimeOffset.Now, TimeSpan.Zero, - Option.None, + Option.None, IsTroubleshooting: false, Option.None);