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/). @@ -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/). @@ -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

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

@ -1,3 +1,5 @@ @@ -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 @@ @@ -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<GetChannelFramerate, Option<int>>
public class GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
: IRequestHandler<GetChannelFramerate, Option<FrameRate>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
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)
public async Task<Option<FrameRate>> 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<GetChannelFramerate, O @@ -34,11 +27,11 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
if (!ffmpegProfile.NormalizeFramerate)
{
return Option<int>.None;
return Option<FrameRate>.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<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O @@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
var frameRates = playouts.Map(p => 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<GetChannelFramerate, O @@ -120,22 +115,4 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
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; @@ -12,6 +12,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.Hosting;
@ -82,7 +83,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -82,7 +83,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken)
.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),
cancellationToken);
@ -122,7 +123,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -122,7 +123,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
return Unit.Default;
}
private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<int> targetFramerate) =>
private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<FrameRate> targetFramerate) =>
request.Mode switch
{
_ => new HlsSessionWorker(

5
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -16,6 +16,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg; @@ -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 @@ -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<int> _targetFramerate;
private readonly Option<FrameRate> _targetFramerate;
private CancellationTokenSource _cancellationTokenSource;
private string _channelNumber;
private DateTimeOffset _channelStart;
@ -65,7 +66,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -65,7 +66,7 @@ public class HlsSessionWorker : IHlsSessionWorker
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
ILogger<HlsSessionWorker> logger,
Option<int> targetFramerate)
Option<FrameRate> targetFramerate)
{
_serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();

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

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

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

@ -12,7 +12,6 @@ using ErsatzTV.Core.FFmpeg; @@ -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< @@ -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<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
@ -50,7 +48,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -50,7 +48,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
@ -68,7 +65,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -68,7 +65,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
{
_ffmpegProcessService = ffmpegProcessService;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;

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

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

11
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -98,7 +98,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -98,7 +98,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
TimeSpan inPoint,
DateTimeOffset channelStartTime,
TimeSpan ptsOffset,
Option<int> targetFramerate,
Option<FrameRate> targetFramerate,
Option<string> customReportsFolder,
Action<FFmpegPipeline> pipelineAction,
bool canProxy,
@ -245,7 +245,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -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 @@ -381,7 +381,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
new FrameSize(1, 1),
string.Empty,
string.Empty,
Option<string>.None,
Option<FrameRate>.None,
!await IsWatermarkAnimated(ffprobePath, wm.ImagePath),
ScanKind.Progressive)
];
@ -520,6 +520,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -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 @@ -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),

3
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs

@ -1,5 +1,6 @@ @@ -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 @@ -28,5 +29,5 @@ public class FFmpegPlaybackSettings
public bool Deinterlace { get; set; }
public Option<int> VideoTrackTimeScale { 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 @@ -52,7 +52,7 @@ public static class FFmpegPlaybackSettingsCalculator
TimeSpan inPoint,
bool hlsRealtime,
StreamInputKind streamInputKind,
Option<int> targetFramerate)
Option<FrameRate> targetFramerate)
{
var result = new FFmpegPlaybackSettings
{
@ -195,7 +195,7 @@ public static class FFmpegPlaybackSettingsCalculator @@ -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) =>

2
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

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

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

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

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

@ -1,6 +1,7 @@ @@ -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( @@ -11,7 +12,7 @@ public record GraphicsEngineContext(
Dictionary<string, object> TemplateVariables,
Resolution SquarePixelFrameSize,
Resolution FrameSize,
int FrameRate,
FrameRate FrameRate,
DateTimeOffset ChannelStartTime,
DateTimeOffset ContentStartTime,
TimeSpan Seek,

2
ErsatzTV.Core/Metadata/FFmpegProfileTemplateDataKey.cs

@ -4,4 +4,6 @@ public static class FFmpegProfileTemplateDataKey @@ -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";
}

10
ErsatzTV.FFmpeg.Tests/MediaStreamTests.cs

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

18
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -38,7 +38,7 @@ public class PipelineBuilderBaseTests @@ -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 @@ -67,7 +67,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080),
Option<FrameSize>.None,
false,
Option<int>.None,
Option<FrameRate>.None,
2000,
4000,
90_000,
@ -138,7 +138,7 @@ public class PipelineBuilderBaseTests @@ -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 @@ -167,7 +167,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080),
Option<FrameSize>.None,
false,
Option<int>.None,
Option<FrameRate>.None,
2000,
4000,
90_000,
@ -296,7 +296,7 @@ public class PipelineBuilderBaseTests @@ -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 @@ -325,7 +325,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080),
Option<FrameSize>.None,
false,
Option<int>.None,
Option<FrameRate>.None,
2000,
4000,
90_000,
@ -400,7 +400,7 @@ public class PipelineBuilderBaseTests @@ -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 @@ -419,7 +419,7 @@ public class PipelineBuilderBaseTests
new FrameSize(1920, 1080),
Option<FrameSize>.None,
false,
Option<int>.None,
Option<FrameRate>.None,
2000,
4000,
90_000,
@ -494,7 +494,7 @@ public class PipelineBuilderBaseTests @@ -494,7 +494,7 @@ public class PipelineBuilderBaseTests
FrameSize.Unknown,
string.Empty,
string.Empty,
Option<string>.None,
Option<FrameRate>.None,
true,
ScanKind.Progressive)
});

6
ErsatzTV.FFmpeg/Filter/FrameRateFilter.cs

@ -3,9 +3,9 @@ @@ -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 @@ -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)

6
ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs

@ -1,8 +1,8 @@ @@ -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 };
}

30
ErsatzTV.FFmpeg/FrameRate.cs

@ -0,0 +1,30 @@ @@ -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( @@ -14,7 +14,7 @@ public record FrameState(
FrameSize PaddedSize,
Option<FrameSize> CroppedSize,
bool IsAnamorphic,
Option<int> FrameRate,
Option<FrameRate> FrameRate,
Option<int> VideoBitrate,
Option<int> VideoBufferSize,
Option<int> VideoTrackTimeScale,

2
ErsatzTV.FFmpeg/InputFile.cs

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

4
ErsatzTV.FFmpeg/InputOption/RawVideoInputOption.cs

@ -2,7 +2,7 @@ @@ -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 @@ -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 => [];

2
ErsatzTV.FFmpeg/MediaStream.cs

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

39
ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs

@ -9,7 +9,7 @@ public class OutputFormatHls : IPipelineStep @@ -9,7 +9,7 @@ public class OutputFormatHls : IPipelineStep
private readonly FrameState _desiredState;
private readonly bool _isFirstTranscode;
private readonly bool _isTroubleshooting;
private readonly Option<string> _mediaFrameRate;
private readonly Option<FrameRate> _mediaFrameRate;
private readonly OutputFormatKind _outputFormat;
private readonly Option<string> _segmentOptions;
private readonly bool _oneSecondGop;
@ -19,7 +19,7 @@ public class OutputFormatHls : IPipelineStep @@ -19,7 +19,7 @@ public class OutputFormatHls : IPipelineStep
public OutputFormatHls(
FrameState desiredState,
Option<string> mediaFrameRate,
Option<FrameRate> mediaFrameRate,
OutputFormatKind outputFormat,
Option<string> segmentOptions,
string segmentTemplate,
@ -50,14 +50,16 @@ public class OutputFormatHls : IPipelineStep @@ -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<string> 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 @@ -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;
}
}

22
ErsatzTV.FFmpeg/OutputOption/FrameRateOutputOption.cs

@ -1,24 +1,18 @@ @@ -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<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 string[] OutputOptions => ["-r", frameRate.RFrameRate, "-vsync", "cfr"];
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 @@ -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 @@ -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 @@ -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));
}

12
ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs

@ -1,4 +1,3 @@ @@ -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 @@ -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<FrameRateOutputOption>().HeadOrNone())
foreach (var fr in pipelineSteps.OfType<FrameRateOutputOption>().HeadOrNone())
{
pipelineSteps.Remove(frameRate);
pipelineSteps.Remove(fr);
}
currentState = SetSubtitle(

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

@ -246,6 +246,8 @@ public partial class GraphicsElementLoader( @@ -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

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

@ -87,7 +87,7 @@ public class GraphicsEngine( @@ -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( @@ -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

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

@ -164,7 +164,7 @@ public class MotionElement( @@ -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}";

3
ErsatzTV/Controllers/InternalController.cs

@ -13,6 +13,7 @@ using ErsatzTV.Core.Domain; @@ -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 @@ -259,7 +260,7 @@ public class InternalController : StreamingControllerBase
true,
DateTimeOffset.Now,
TimeSpan.Zero,
Option<int>.None,
Option<FrameRate>.None,
IsTroubleshooting: false,
Option<int>.None);

3
ErsatzTV/Controllers/IptvController.cs

@ -13,6 +13,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg; @@ -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 @@ -346,7 +347,7 @@ public class IptvController : StreamingControllerBase
true,
DateTimeOffset.Now,
TimeSpan.Zero,
Option<int>.None,
Option<FrameRate>.None,
IsTroubleshooting: false,
Option<int>.None);

Loading…
Cancel
Save