Browse Source

framerate improvements (#2692)

* framerate improvements

* fixes
pull/2693/head
Jason Dove 1 month ago committed by GitHub
parent
commit
54606c76f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      CHANGELOG.md
  2. 6
      ErsatzTV.Application/Channels/Queries/GetChannelFramerate.cs
  3. 83
      ErsatzTV.Application/Channels/Queries/GetChannelFramerateHandler.cs
  4. 5
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  5. 5
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  6. 3
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  7. 4
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  8. 4
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  9. 11
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  10. 3
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs
  11. 4
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  12. 2
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  13. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  14. 3
      ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs
  15. 2
      ErsatzTV.Core/Metadata/FFmpegProfileTemplateDataKey.cs
  16. 10
      ErsatzTV.FFmpeg.Tests/MediaStreamTests.cs
  17. 18
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  18. 6
      ErsatzTV.FFmpeg/Filter/FrameRateFilter.cs
  19. 6
      ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs
  20. 30
      ErsatzTV.FFmpeg/FrameRate.cs
  21. 2
      ErsatzTV.FFmpeg/FrameState.cs
  22. 2
      ErsatzTV.FFmpeg/InputFile.cs
  23. 4
      ErsatzTV.FFmpeg/InputOption/RawVideoInputOption.cs
  24. 2
      ErsatzTV.FFmpeg/MediaStream.cs
  25. 39
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs
  26. 22
      ErsatzTV.FFmpeg/OutputOption/FrameRateOutputOption.cs
  27. 21
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  28. 12
      ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs
  29. 2
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs
  30. 4
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs
  31. 2
      ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs
  32. 3
      ErsatzTV/Controllers/InternalController.cs
  33. 3
      ErsatzTV/Controllers/IptvController.cs

7
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 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) - 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 - 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 ### Fixed
- Fix startup on systems unsupported by NvEncSharp - 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 - 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 - 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 - 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 ## [25.9.0] - 2025-11-29
### Added ### Added

6
ErsatzTV.Application/Channels/Queries/GetChannelFramerate.cs

@ -1,3 +1,5 @@
namespace ErsatzTV.Application.Channels; using ErsatzTV.FFmpeg;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<int>>; namespace ErsatzTV.Application.Channels;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<FrameRate>>;

83
ErsatzTV.Application/Channels/Queries/GetChannelFramerateHandler.cs

@ -1,29 +1,22 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, Option<int>> public class GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
: IRequestHandler<GetChannelFramerate, Option<FrameRate>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
private readonly ILogger<GetChannelFramerateHandler> _logger;
public GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
{ {
try try
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
FFmpegProfile ffmpegProfile = await dbContext.Channels FFmpegProfile ffmpegProfile = await dbContext.Channels
.AsNoTracking() .AsNoTracking()
@ -34,11 +27,11 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
if (!ffmpegProfile.NormalizeFramerate) if (!ffmpegProfile.NormalizeFramerate)
{ {
return Option<int>.None; return Option<FrameRate>.None;
} }
// TODO: expand to check everything in collection rather than what's scheduled? // 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<Playout> playouts = await dbContext.Playouts List<Playout> playouts = await dbContext.Playouts
.AsNoTracking() .AsNoTracking()
@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion())) var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
.Flatten() .Flatten()
.Map(mv => mv.RFrameRate) .Map(mv => new FrameRate(mv.RFrameRate))
.ToList(); .ToList();
var distinct = frameRates.Distinct().ToList(); var distinct = frameRates.Distinct().ToList();
if (distinct.Count > 1) if (distinct.Count > 1)
{ {
// TODO: something more intelligent than minimum framerate? // TODO: something more intelligent than minimum framerate?
int result = frameRates.Map(ParseFrameRate).Min(); var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
if (result < 24) if (validFrameRates.Count > 0)
{ {
_logger.LogInformation( FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}", logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
request.ChannelNumber, request.ChannelNumber,
distinct, distinct.Map(fr => fr.RFrameRate),
24, result.RFrameRate);
result); return result;
return 24;
} }
_logger.LogInformation( FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate);
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}", logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
request.ChannelNumber, request.ChannelNumber,
distinct, distinct.Map(fr => fr.RFrameRate),
result); FrameRate.DefaultFrameRate.RFrameRate,
return result; minFrameRate.RFrameRate);
return FrameRate.DefaultFrameRate;
} }
if (distinct.Count != 0) if (distinct.Count != 0)
{ {
_logger.LogInformation( logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize", "All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber, request.ChannelNumber,
distinct[0]); distinct[0].RFrameRate);
} }
else else
{ {
_logger.LogInformation( logger.LogInformation(
"No content on channel {ChannelNumber} has frame rate information; will not normalize", "No content on channel {ChannelNumber} has frame rate information; will not normalize",
request.ChannelNumber); request.ChannelNumber);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning( logger.LogWarning(
ex, ex,
"Unexpected error checking frame rates on channel {ChannelNumber}", "Unexpected error checking frame rates on channel {ChannelNumber}",
request.ChannelNumber); request.ChannelNumber);
@ -120,22 +115,4 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
return None; return None;
} }
private int ParseFrameRate(string frameRate)
{
if (!int.TryParse(frameRate, out int fr))
{
string[] split = (frameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
fr = (int)Math.Round(left / (double)right);
}
else
{
fr = 24;
}
}
return fr;
}
} }

5
ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs

@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.OutputFormat; using ErsatzTV.FFmpeg.OutputFormat;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@ -82,7 +83,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken) .GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken)
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1))); .Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
Option<int> targetFramerate = await _mediator.Send( Option<FrameRate> targetFramerate = await _mediator.Send(
new GetChannelFramerate(request.ChannelNumber), new GetChannelFramerate(request.ChannelNumber),
cancellationToken); cancellationToken);
@ -122,7 +123,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
return Unit.Default; return Unit.Default;
} }
private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<int> targetFramerate) => private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<FrameRate> targetFramerate) =>
request.Mode switch request.Mode switch
{ {
_ => new HlsSessionWorker( _ => new HlsSessionWorker(

5
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -16,6 +16,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.OutputFormat; using ErsatzTV.FFmpeg.OutputFormat;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -39,7 +40,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly SemaphoreSlim _slim = new(1, 1); private readonly SemaphoreSlim _slim = new(1, 1);
private readonly Lock _sync = new(); private readonly Lock _sync = new();
private readonly Option<int> _targetFramerate; private readonly Option<FrameRate> _targetFramerate;
private CancellationTokenSource _cancellationTokenSource; private CancellationTokenSource _cancellationTokenSource;
private string _channelNumber; private string _channelNumber;
private DateTimeOffset _channelStart; private DateTimeOffset _channelStart;
@ -65,7 +66,7 @@ public class HlsSessionWorker : IHlsSessionWorker
IFileSystem fileSystem, IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<HlsSessionWorker> logger, ILogger<HlsSessionWorker> logger,
Option<int> targetFramerate) Option<FrameRate> targetFramerate)
{ {
_serviceScope = serviceScopeFactory.CreateScope(); _serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>(); _mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();

3
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
@ -10,7 +11,7 @@ public record GetPlayoutItemProcessByChannelNumber(
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStart, DateTimeOffset ChannelStart,
TimeSpan PtsOffset, TimeSpan PtsOffset,
Option<int> TargetFramerate, Option<FrameRate> TargetFramerate,
bool IsTroubleshooting, bool IsTroubleshooting,
Option<int> FFmpegProfileId) : FFmpegProcessRequest( Option<int> FFmpegProfileId) : FFmpegProcessRequest(
ChannelNumber, ChannelNumber,

4
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -12,7 +12,6 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
@ -34,7 +33,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly IFFmpegProcessService _ffmpegProcessService; private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IFileSystem _fileSystem; private readonly IFileSystem _fileSystem;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger; private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator; private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
@ -50,7 +48,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
IFileSystem fileSystem, IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider, IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider,
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService,
@ -68,7 +65,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
{ {
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
_fileSystem = fileSystem; _fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider; _externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
_plexPathReplacementService = plexPathReplacementService; _plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService; _jellyfinPathReplacementService = jellyfinPathReplacementService;

4
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -87,7 +87,7 @@ public class PrepareTroubleshootingPlaybackHandler(
HlsRealtime: false, HlsRealtime: false,
start, start,
TimeSpan.Zero, TimeSpan.Zero,
TargetFramerate: Option<int>.None, TargetFramerate: Option<FrameRate>.None,
IsTroubleshooting: true, IsTroubleshooting: true,
request.FFmpegProfileId), request.FFmpegProfileId),
cancellationToken); cancellationToken);
@ -318,7 +318,7 @@ public class PrepareTroubleshootingPlaybackHandler(
inPoint, inPoint,
channelStartTime: DateTimeOffset.Now, channelStartTime: DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
Option<int>.None, Option<FrameRate>.None,
FileSystemLayout.TranscodeTroubleshootingFolder, FileSystemLayout.TranscodeTroubleshootingFolder,
_ => { }, _ => { },
canProxy: true, canProxy: true,

11
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -98,7 +98,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
TimeSpan inPoint, TimeSpan inPoint,
DateTimeOffset channelStartTime, DateTimeOffset channelStartTime,
TimeSpan ptsOffset, TimeSpan ptsOffset,
Option<int> targetFramerate, Option<FrameRate> targetFramerate,
Option<string> customReportsFolder, Option<string> customReportsFolder,
Action<FFmpegPipeline> pipelineAction, Action<FFmpegPipeline> pipelineAction,
bool canProxy, bool canProxy,
@ -245,7 +245,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
new FrameSize(videoVersion.MediaVersion.Width, videoVersion.MediaVersion.Height), new FrameSize(videoVersion.MediaVersion.Width, videoVersion.MediaVersion.Height),
videoVersion.MediaVersion.SampleAspectRatio, videoVersion.MediaVersion.SampleAspectRatio,
videoVersion.MediaVersion.DisplayAspectRatio, videoVersion.MediaVersion.DisplayAspectRatio,
videoVersion.MediaVersion.RFrameRate, new FrameRate(videoVersion.MediaVersion.RFrameRate),
videoPath != audioPath, // still image when paths are different videoPath != audioPath, // still image when paths are different
scanKind); scanKind);
@ -381,7 +381,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
new FrameSize(1, 1), new FrameSize(1, 1),
string.Empty, string.Empty,
string.Empty, string.Empty,
Option<string>.None, Option<FrameRate>.None,
!await IsWatermarkAnimated(ffprobePath, wm.ImagePath), !await IsWatermarkAnimated(ffprobePath, wm.ImagePath),
ScanKind.Progressive) ScanKind.Progressive)
]; ];
@ -520,6 +520,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{ {
FrameSize targetSize = await desiredState.CroppedSize.IfNoneAsync(desiredState.ScaledSize); FrameSize targetSize = await desiredState.CroppedSize.IfNoneAsync(desiredState.ScaledSize);
FrameRate frameRate = await playbackSettings.FrameRate
.IfNoneAsync(new FrameRate(videoVersion.MediaVersion.RFrameRate));
var context = new GraphicsEngineContext( var context = new GraphicsEngineContext(
channel.Number, channel.Number,
audioVersion.MediaItem, audioVersion.MediaItem,
@ -527,7 +530,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
TemplateVariables: [], TemplateVariables: [],
new Resolution { Width = targetSize.Width, Height = targetSize.Height }, new Resolution { Width = targetSize.Width, Height = targetSize.Height },
channel.FFmpegProfile.Resolution, channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24), frameRate,
channelStartTime, channelStartTime,
start, start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero), await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),

3
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs

@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Format; using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
@ -28,5 +29,5 @@ public class FFmpegPlaybackSettings
public bool Deinterlace { get; set; } public bool Deinterlace { get; set; }
public Option<int> VideoTrackTimeScale { get; set; } public Option<int> VideoTrackTimeScale { get; set; }
public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; } public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; }
public Option<int> FrameRate { get; set; } public Option<FrameRate> FrameRate { get; set; }
} }

4
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -52,7 +52,7 @@ public static class FFmpegPlaybackSettingsCalculator
TimeSpan inPoint, TimeSpan inPoint,
bool hlsRealtime, bool hlsRealtime,
StreamInputKind streamInputKind, StreamInputKind streamInputKind,
Option<int> targetFramerate) Option<FrameRate> targetFramerate)
{ {
var result = new FFmpegPlaybackSettings var result = new FFmpegPlaybackSettings
{ {
@ -195,7 +195,7 @@ public static class FFmpegPlaybackSettingsCalculator
_ => true _ => true
}, },
VideoTrackTimeScale = 90000, VideoTrackTimeScale = 90000,
FrameRate = 24 FrameRate = FrameRate.DefaultFrameRate
}; };
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version, string sampleAspectRatio) => private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version, string sampleAspectRatio) =>

2
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -86,7 +86,7 @@ public class FFmpegProcessService
TimeSpan.Zero, TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
Option<int>.None); Option<FrameRate>.None);
scalePlaybackSettings.AudioChannels = Option<int>.None; scalePlaybackSettings.AudioChannels = Option<int>.None;

2
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -39,7 +39,7 @@ public interface IFFmpegProcessService
TimeSpan inPoint, TimeSpan inPoint,
DateTimeOffset channelStartTime, DateTimeOffset channelStartTime,
TimeSpan ptsOffset, TimeSpan ptsOffset,
Option<int> targetFramerate, Option<FrameRate> targetFramerate,
Option<string> customReportsFolder, Option<string> customReportsFolder,
Action<FFmpegPipeline> pipelineAction, Action<FFmpegPipeline> pipelineAction,
bool canProxy, bool canProxy,

3
ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs

@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Graphics; using ErsatzTV.Core.Graphics;
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Core.Interfaces.Streaming; namespace ErsatzTV.Core.Interfaces.Streaming;
@ -11,7 +12,7 @@ public record GraphicsEngineContext(
Dictionary<string, object> TemplateVariables, Dictionary<string, object> TemplateVariables,
Resolution SquarePixelFrameSize, Resolution SquarePixelFrameSize,
Resolution FrameSize, Resolution FrameSize,
int FrameRate, FrameRate FrameRate,
DateTimeOffset ChannelStartTime, DateTimeOffset ChannelStartTime,
DateTimeOffset ContentStartTime, DateTimeOffset ContentStartTime,
TimeSpan Seek, TimeSpan Seek,

2
ErsatzTV.Core/Metadata/FFmpegProfileTemplateDataKey.cs

@ -4,4 +4,6 @@ public static class FFmpegProfileTemplateDataKey
{ {
public static readonly string Resolution = "Resolution"; public static readonly string Resolution = "Resolution";
public static readonly string ScaledResolution = "ScaledResolution"; public static readonly string ScaledResolution = "ScaledResolution";
public static readonly string RFrameRate = "RFrameRate";
public static readonly string FrameRate = "FrameRate";
} }

10
ErsatzTV.FFmpeg.Tests/MediaStreamTests.cs

@ -21,7 +21,7 @@ public class MediaStreamTests
FrameSize.Unknown, FrameSize.Unknown,
"0:0", "0:0",
"4:3", "4:3",
Option<string>.None, Option<FrameRate>.None,
false, false,
ScanKind.Progressive); ScanKind.Progressive);
@ -41,7 +41,7 @@ public class MediaStreamTests
FrameSize.Unknown, FrameSize.Unknown,
"0:0", "0:0",
"4:3", "4:3",
Option<string>.None, Option<FrameRate>.None,
false, false,
ScanKind.Progressive); ScanKind.Progressive);
@ -61,7 +61,7 @@ public class MediaStreamTests
FrameSize.Unknown, FrameSize.Unknown,
"1:1", "1:1",
"16:9", "16:9",
Option<string>.None, Option<FrameRate>.None,
false, false,
ScanKind.Progressive); ScanKind.Progressive);
@ -81,7 +81,7 @@ public class MediaStreamTests
FrameSize.Unknown, FrameSize.Unknown,
"32:27", "32:27",
"16:9", "16:9",
Option<string>.None, Option<FrameRate>.None,
false, false,
ScanKind.Progressive); ScanKind.Progressive);
@ -101,7 +101,7 @@ public class MediaStreamTests
FrameSize.Unknown, FrameSize.Unknown,
"1.5:3.5", "1.5:3.5",
"16:9", "16:9",
Option<string>.None, Option<FrameRate>.None,
false, false,
ScanKind.Progressive); ScanKind.Progressive);

18
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -38,7 +38,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080), new FrameSize(1920, 1080),
"1:1", "1:1",
"16:9", "16:9",
"24", FrameRate.DefaultFrameRate,
false, false,
ScanKind.Progressive) ScanKind.Progressive)
}); });
@ -67,7 +67,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080), new FrameSize(1920, 1080),
Option<FrameSize>.None, Option<FrameSize>.None,
false, false,
Option<int>.None, Option<FrameRate>.None,
2000, 2000,
4000, 4000,
90_000, 90_000,
@ -138,7 +138,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080), new FrameSize(1920, 1080),
"1:1", "1:1",
"16:9", "16:9",
"24", FrameRate.DefaultFrameRate,
false, false,
ScanKind.Progressive) ScanKind.Progressive)
}); });
@ -167,7 +167,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080), new FrameSize(1920, 1080),
Option<FrameSize>.None, Option<FrameSize>.None,
false, false,
Option<int>.None, Option<FrameRate>.None,
2000, 2000,
4000, 4000,
90_000, 90_000,
@ -296,7 +296,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080), new FrameSize(1920, 1080),
"1:1", "1:1",
"16:9", "16:9",
"24", FrameRate.DefaultFrameRate,
false, false,
ScanKind.Interlaced) ScanKind.Interlaced)
}); });
@ -325,7 +325,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080), new FrameSize(1920, 1080),
Option<FrameSize>.None, Option<FrameSize>.None,
false, false,
Option<int>.None, Option<FrameRate>.None,
2000, 2000,
4000, 4000,
90_000, 90_000,
@ -400,7 +400,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080), new FrameSize(1920, 1080),
"1:1", "1:1",
"16:9", "16:9",
"24", FrameRate.DefaultFrameRate,
false, false,
ScanKind.Progressive) ScanKind.Progressive)
}); });
@ -419,7 +419,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080), new FrameSize(1920, 1080),
Option<FrameSize>.None, Option<FrameSize>.None,
false, false,
Option<int>.None, Option<FrameRate>.None,
2000, 2000,
4000, 4000,
90_000, 90_000,
@ -494,7 +494,7 @@ public class PipelineBuilderBaseTests
FrameSize.Unknown, FrameSize.Unknown,
string.Empty, string.Empty,
string.Empty, string.Empty,
Option<string>.None, Option<FrameRate>.None,
true, true,
ScanKind.Progressive) ScanKind.Progressive)
}); });

6
ErsatzTV.FFmpeg/Filter/FrameRateFilter.cs

@ -3,9 +3,9 @@
public class FrameRateFilter : BaseFilter public class FrameRateFilter : BaseFilter
{ {
private readonly FrameState _currentState; 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; _currentState = currentState;
_frameRate = frameRate; _frameRate = frameRate;
@ -15,7 +15,7 @@ public class FrameRateFilter : BaseFilter
{ {
get 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); string pixelFormat = _currentState.PixelFormat.Match(pf => pf.FFmpegName, () => string.Empty);
if (_currentState.FrameDataLocation == FrameDataLocation.Hardware) if (_currentState.FrameDataLocation == FrameDataLocation.Hardware)

6
ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs

@ -1,8 +1,8 @@
namespace ErsatzTV.FFmpeg.Filter; 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 };
} }

30
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;
}
}

2
ErsatzTV.FFmpeg/FrameState.cs

@ -14,7 +14,7 @@ public record FrameState(
FrameSize PaddedSize, FrameSize PaddedSize,
Option<FrameSize> CroppedSize, Option<FrameSize> CroppedSize,
bool IsAnamorphic, bool IsAnamorphic,
Option<int> FrameRate, Option<FrameRate> FrameRate,
Option<int> VideoBitrate, Option<int> VideoBitrate,
Option<int> VideoBufferSize, Option<int> VideoBufferSize,
Option<int> VideoTrackTimeScale, Option<int> VideoTrackTimeScale,

2
ErsatzTV.FFmpeg/InputFile.cs

@ -23,7 +23,7 @@ public record ConcatInputFile(string Url, FrameSize Resolution) : InputFile(
Resolution, Resolution,
string.Empty, string.Empty,
string.Empty, string.Empty,
Option<string>.None, Option<FrameRate>.None,
false, false,
ScanKind.Unknown) ScanKind.Unknown)
}) })

4
ErsatzTV.FFmpeg/InputOption/RawVideoInputOption.cs

@ -2,7 +2,7 @@
namespace ErsatzTV.FFmpeg.InputOption; 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 EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => []; public string[] GlobalOptions => [];
@ -13,7 +13,7 @@ public class RawVideoInputOption(string pixelFormat, FrameSize frameSize, int fr
"-vcodec", "rawvideo", "-vcodec", "rawvideo",
"-pix_fmt", pixelFormat, "-pix_fmt", pixelFormat,
"-s", $"{frameSize.Width}x{frameSize.Height}", "-s", $"{frameSize.Width}x{frameSize.Height}",
"-r", $"{frameRate}" "-r", $"{frameRate.RFrameRate}"
]; ];
public string[] FilterOptions => []; public string[] FilterOptions => [];

2
ErsatzTV.FFmpeg/MediaStream.cs

@ -23,7 +23,7 @@ public record VideoStream(
FrameSize FrameSize, FrameSize FrameSize,
string MaybeSampleAspectRatio, string MaybeSampleAspectRatio,
string DisplayAspectRatio, string DisplayAspectRatio,
Option<string> FrameRate, Option<FrameRate> FrameRate,
bool StillImage, bool StillImage,
ScanKind ScanKind) : MediaStream( ScanKind ScanKind) : MediaStream(
Index, Index,

39
ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs

@ -9,7 +9,7 @@ public class OutputFormatHls : IPipelineStep
private readonly FrameState _desiredState; private readonly FrameState _desiredState;
private readonly bool _isFirstTranscode; private readonly bool _isFirstTranscode;
private readonly bool _isTroubleshooting; private readonly bool _isTroubleshooting;
private readonly Option<string> _mediaFrameRate; private readonly Option<FrameRate> _mediaFrameRate;
private readonly OutputFormatKind _outputFormat; private readonly OutputFormatKind _outputFormat;
private readonly Option<string> _segmentOptions; private readonly Option<string> _segmentOptions;
private readonly bool _oneSecondGop; private readonly bool _oneSecondGop;
@ -19,7 +19,7 @@ public class OutputFormatHls : IPipelineStep
public OutputFormatHls( public OutputFormatHls(
FrameState desiredState, FrameState desiredState,
Option<string> mediaFrameRate, Option<FrameRate> mediaFrameRate,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
Option<string> segmentOptions, Option<string> segmentOptions,
string segmentTemplate, string segmentTemplate,
@ -50,14 +50,16 @@ public class OutputFormatHls : IPipelineStep
{ {
get 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<string> result = List<string> result =
[ [
"-g", $"{gop}", "-g", $"{gop}",
"-keyint_min", $"{frameRate * SegmentSeconds}", "-keyint_min", $"{(int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds)}",
"-force_key_frames", $"expr:gte(t,n_forced*{SegmentSeconds})", "-force_key_frames", $"expr:gte(t,n_forced*{SegmentSeconds})",
"-f", "hls", "-f", "hls",
"-hls_time", $"{SegmentSeconds}", "-hls_time", $"{SegmentSeconds}",
@ -127,31 +129,4 @@ public class OutputFormatHls : IPipelineStep
} }
public FrameState NextState(FrameState currentState) => currentState; 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;
}
} }

22
ErsatzTV.FFmpeg/OutputOption/FrameRateOutputOption.cs

@ -1,24 +1,18 @@
using System.Globalization; using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.OutputOption; 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 string[] OutputOptions => ["-r", frameRate.RFrameRate, "-vsync", "cfr"];
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public string[] InputOptions(InputFile inputFile) => Array.Empty<string>();
public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions => new[]
{ "-r", _frameRate.ToString(CultureInfo.InvariantCulture), "-vsync", "cfr" };
public FrameState NextState(FrameState currentState) => currentState with public FrameState NextState(FrameState currentState) => currentState with
{ {
FrameRate = _frameRate FrameRate = frameRate
}; };
} }

21
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -229,14 +229,6 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
concatInputFile.AddOption(new InfiniteLoopInputOption(HardwareAccelerationMode.None)); 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"); Debug.Assert(_videoInputFile.IsSome, "Pipeline builder requires exactly one video input file");
VideoInputFile videoInputFile = _videoInputFile.Head(); 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"); Debug.Assert(allVideoStreams.Count == 1, "Pipeline builder requires exactly one video stream");
VideoStream videoStream = allVideoStreams.Head(); 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( var context = new PipelineContext(
_hardwareAccelerationMode, _hardwareAccelerationMode,
_graphicsEngineInput.IsSome, _graphicsEngineInput.IsSome,
@ -754,7 +757,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
return; return;
} }
foreach (int desiredFrameRate in desiredState.FrameRate) foreach (FrameRate desiredFrameRate in desiredState.FrameRate)
{ {
pipelineSteps.Add(new FrameRateOutputOption(desiredFrameRate)); pipelineSteps.Add(new FrameRateOutputOption(desiredFrameRate));
} }

12
ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs

@ -1,4 +1,3 @@
using System.Globalization;
using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Decoder; using ErsatzTV.FFmpeg.Decoder;
using ErsatzTV.FFmpeg.Decoder.Qsv; using ErsatzTV.FFmpeg.Decoder.Qsv;
@ -203,15 +202,14 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
} }
// use normalized fps, or source fps // use normalized fps, or source fps
string fps = desiredState.FrameRate.Match( FrameRate frameRate =
r => r.ToString(NumberFormatInfo.InvariantInfo), desiredState.FrameRate.IfNone(videoStream.FrameRate.IfNone(FrameRate.DefaultFrameRate));
() => videoStream.FrameRate.IfNone("24")); videoInputFile.FilterSteps.Add(new ResetPtsFilter(frameRate));
videoInputFile.FilterSteps.Add(new ResetPtsFilter(fps));
// since fps will set frame rate, remove the output option // since fps will set frame rate, remove the output option
foreach (var frameRate in pipelineSteps.OfType<FrameRateOutputOption>().HeadOrNone()) foreach (var fr in pipelineSteps.OfType<FrameRateOutputOption>().HeadOrNone())
{ {
pipelineSteps.Remove(frameRate); pipelineSteps.Remove(fr);
} }
currentState = SetSubtitle( currentState = SetSubtitle(

2
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs

@ -246,6 +246,8 @@ public partial class GraphicsElementLoader(
{ {
[FFmpegProfileTemplateDataKey.Resolution] = context.FrameSize, [FFmpegProfileTemplateDataKey.Resolution] = context.FrameSize,
[FFmpegProfileTemplateDataKey.ScaledResolution] = context.SquarePixelFrameSize, [FFmpegProfileTemplateDataKey.ScaledResolution] = context.SquarePixelFrameSize,
[FFmpegProfileTemplateDataKey.RFrameRate] = context.FrameRate.RFrameRate,
[FFmpegProfileTemplateDataKey.FrameRate] = context.FrameRate.ParsedFrameRate,
[MediaItemTemplateDataKey.StreamSeek] = context.Seek, [MediaItemTemplateDataKey.StreamSeek] = context.Seek,
[MediaItemTemplateDataKey.Start] = context.ContentStartTime, [MediaItemTemplateDataKey.Start] = context.ContentStartTime,
[MediaItemTemplateDataKey.Stop] = context.ContentStartTime + context.Duration [MediaItemTemplateDataKey.Stop] = context.ContentStartTime + context.Duration

4
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs

@ -87,7 +87,7 @@ public class GraphicsEngine(
await Task.WhenAll(elements.Select(e => e.InitializeAsync(context, cancellationToken))); await Task.WhenAll(elements.Select(e => e.InitializeAsync(context, cancellationToken)));
long frameCount = 0; 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 width = context.FrameSize.Width;
int height = context.FrameSize.Height; int height = context.FrameSize.Height;
@ -107,7 +107,7 @@ public class GraphicsEngine(
while (!cancellationToken.IsCancellationRequested && frameCount < totalFrames) while (!cancellationToken.IsCancellationRequested && frameCount < totalFrames)
{ {
// seconds since this specific stream started // seconds since this specific stream started
double streamTimeSeconds = (double)frameCount / context.FrameRate; double streamTimeSeconds = frameCount / context.FrameRate.ParsedFrameRate;
var streamTime = TimeSpan.FromSeconds(streamTimeSeconds); var streamTime = TimeSpan.FromSeconds(streamTimeSeconds);
// `content_seconds` - the total number of seconds the frame is into the content // `content_seconds` - the total number of seconds the frame is into the content

2
ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs

@ -164,7 +164,7 @@ public class MotionElement(
"-i", motionElement.VideoPath, "-i", motionElement.VideoPath,
]); ]);
var videoFilter = $"fps={context.FrameRate}"; var videoFilter = $"fps={context.FrameRate.RFrameRate}";
if (motionElement.Scale) if (motionElement.Scale)
{ {
videoFilter += $",scale={targetSize.Width}:{targetSize.Height}"; videoFilter += $",scale={targetSize.Width}:{targetSize.Height}";

3
ErsatzTV/Controllers/InternalController.cs

@ -13,6 +13,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Extensions; using ErsatzTV.Extensions;
using ErsatzTV.FFmpeg;
using Flurl; using Flurl;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -259,7 +260,7 @@ public class InternalController : StreamingControllerBase
true, true,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
Option<int>.None, Option<FrameRate>.None,
IsTroubleshooting: false, IsTroubleshooting: false,
Option<int>.None); Option<int>.None);

3
ErsatzTV/Controllers/IptvController.cs

@ -13,6 +13,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
using ErsatzTV.Extensions; using ErsatzTV.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Filters; using ErsatzTV.Filters;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -346,7 +347,7 @@ public class IptvController : StreamingControllerBase
true, true,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
Option<int>.None, Option<FrameRate>.None,
IsTroubleshooting: false, IsTroubleshooting: false,
Option<int>.None); Option<int>.None);

Loading…
Cancel
Save