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);