diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b189a6cf..30b51a33b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix searching for `collection` names with spaces or other special characters, e.g. `collection:"Movies - Action"` - Fix QSV transcoding errors when scaling - Fix QSV frame freezing in browser +- Fix some stream continuity issues, and some cases where audio sync is lost at transition ## [25.2.0] - 2025-06-24 ### Added diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index 3497b5480..bd1a6c12b 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -397,7 +397,7 @@ public class HlsSessionWorker : IHlsSessionWorker } } - long ptsOffset = await GetPtsOffset(_channelNumber, cancellationToken); + double ptsOffset = await GetPtsOffset(_channelNumber, cancellationToken); // _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset); _logger.LogDebug("HLS session state: {State}", _state); @@ -620,12 +620,12 @@ public class HlsSessionWorker : IHlsSessionWorker } } - private async Task GetPtsOffset(string channelNumber, CancellationToken cancellationToken) + private async Task GetPtsOffset(string channelNumber, CancellationToken cancellationToken) { await _slim.WaitAsync(cancellationToken); try { - long result = 0; + double result = 0; // if we haven't yet written any segments, start at zero if (!_hasWrittenSegments) @@ -633,8 +633,8 @@ public class HlsSessionWorker : IHlsSessionWorker return result; } - Either queryResult = await _mediator.Send( - new GetLastPtsDuration(channelNumber), + Either queryResult = await _mediator.Send( + new GetLastPtsTime(channelNumber), cancellationToken); foreach (BaseError error in queryResult.LeftToSeq()) @@ -642,9 +642,9 @@ public class HlsSessionWorker : IHlsSessionWorker _logger.LogWarning("Unable to determine last pts offset - {Error}", error.ToString()); } - foreach ((long pts, long duration) in queryResult.RightToSeq()) + foreach (PtsTime pts in queryResult.RightToSeq()) { - result = pts + duration + 1; + result = pts.Value + 0.01; } return result; diff --git a/ErsatzTV.Application/Streaming/PtsAndDuration.cs b/ErsatzTV.Application/Streaming/PtsAndDuration.cs deleted file mode 100644 index fb11f1cdf..000000000 --- a/ErsatzTV.Application/Streaming/PtsAndDuration.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Globalization; - -namespace ErsatzTV.Application.Streaming; - -public record PtsAndDuration(long Pts, long Duration) -{ - public static PtsAndDuration From(string ffprobeLine) - { - string[] split = ffprobeLine.Split("|"); - var left = long.Parse(split[0], CultureInfo.InvariantCulture); - if (!long.TryParse(split[1], out long right)) - { - // some durations are N/A, so we have to guess at something - right = 10_000; - } - - return new PtsAndDuration(left, right); - } -} diff --git a/ErsatzTV.Application/Streaming/PtsTime.cs b/ErsatzTV.Application/Streaming/PtsTime.cs new file mode 100644 index 000000000..f2f446d6b --- /dev/null +++ b/ErsatzTV.Application/Streaming/PtsTime.cs @@ -0,0 +1,19 @@ +using System.Globalization; + +namespace ErsatzTV.Application.Streaming; + +public record PtsTime(double Value) +{ + public static readonly PtsTime Zero = new(0); + + public static PtsTime From(string ffprobeLine) + { + string[] split = ffprobeLine.Split("|"); + var ptsTime = double.Parse(split[0], CultureInfo.InvariantCulture); + if (double.TryParse(split[1], CultureInfo.InvariantCulture, out double duration)) + { + ptsTime += duration; + } + return new PtsTime(ptsTime); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs index 681b4bbde..19c8c6c67 100644 --- a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs +++ b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs @@ -8,4 +8,4 @@ public record FFmpegProcessRequest( DateTimeOffset Now, bool StartAtZero, bool HlsRealtime, - long PtsOffset) : IRequest>; + double PtsOffset) : IRequest>; diff --git a/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs b/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs index 388746caa..aaa6ac6f5 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs @@ -4,7 +4,7 @@ public record GetErrorProcess( string ChannelNumber, string Mode, bool HlsRealtime, - long PtsOffset, + double PtsOffset, Option MaybeDuration, DateTimeOffset Until, string ErrorMessage) : FFmpegProcessRequest( diff --git a/ErsatzTV.Application/Streaming/Queries/GetLastPtsDuration.cs b/ErsatzTV.Application/Streaming/Queries/GetLastPtsDuration.cs deleted file mode 100644 index 9f854d8be..000000000 --- a/ErsatzTV.Application/Streaming/Queries/GetLastPtsDuration.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ErsatzTV.Core; - -namespace ErsatzTV.Application.Streaming; - -public record GetLastPtsDuration(string ChannelNumber) : IRequest>; diff --git a/ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs b/ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs new file mode 100644 index 000000000..0b77cd5cf --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Streaming; + +public record GetLastPtsTime(string ChannelNumber) : IRequest>; diff --git a/ErsatzTV.Application/Streaming/Queries/GetLastPtsDurationHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs similarity index 66% rename from ErsatzTV.Application/Streaming/Queries/GetLastPtsDurationHandler.cs rename to ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs index 7146e5e6f..3b40f791e 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetLastPtsDurationHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs @@ -12,20 +12,20 @@ using Newtonsoft.Json; namespace ErsatzTV.Application.Streaming; -public class GetLastPtsDurationHandler : IRequestHandler> +public class GetLastPtsTimeHandler : IRequestHandler> { private readonly IClient _client; private readonly IConfigElementRepository _configElementRepository; private readonly ILocalFileSystem _localFileSystem; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly ITempFilePool _tempFilePool; - public GetLastPtsDurationHandler( + public GetLastPtsTimeHandler( IClient client, ILocalFileSystem localFileSystem, ITempFilePool tempFilePool, IConfigElementRepository configElementRepository, - ILogger logger) + ILogger logger) { _client = client; _localFileSystem = localFileSystem; @@ -34,68 +34,78 @@ public class GetLastPtsDurationHandler : IRequestHandler> Handle( - GetLastPtsDuration request, + public async Task> Handle( + GetLastPtsTime request, CancellationToken cancellationToken) { Validation validation = await Validate(request); return await validation.Match( parameters => Handle(parameters, cancellationToken), - error => Task.FromResult>(error.Join())); + error => Task.FromResult>(error.Join())); } - private async Task> Validate(GetLastPtsDuration request) => + private async Task> Validate(GetLastPtsTime request) => await ValidateFFprobePath().MapT(ffprobePath => new RequestParameters(request.ChannelNumber, ffprobePath)); - private async Task> Handle( + private async Task> Handle( RequestParameters parameters, CancellationToken cancellationToken) { Option maybeLastSegment = GetLastSegment(parameters.ChannelNumber); foreach (FileInfo segment in maybeLastSegment) { - string[] argumentList = - { - "-v", "0", - "-show_entries", - "packet=pts,duration", - "-of", "compact=p=0:nk=1", - // "-read_intervals", "999999", // read_intervals causes inconsistent behavior on windows - segment.FullName - }; - - string lastLine = string.Empty; - Action replaceLine = s => - { - if (!string.IsNullOrWhiteSpace(s)) - { - lastLine = s.Trim(); - } - }; - - CommandResult probe = await Cli.Wrap(parameters.FFprobePath) - .WithArguments(argumentList) - .WithValidation(CommandResultValidation.None) - .WithStandardOutputPipe(PipeTarget.ToDelegate(replaceLine)) - .ExecuteAsync(cancellationToken); - - if (probe.ExitCode != 0) - { - return BaseError.New($"FFprobe at {parameters.FFprobePath} exited with code {probe.ExitCode}"); - } + PtsTime videoPts = await GetPts(parameters, segment, "v", cancellationToken).IfNoneAsync(PtsTime.Zero); + PtsTime audioPts = await GetPts(parameters, segment, "a", cancellationToken).IfNoneAsync(PtsTime.Zero); + return videoPts.Value > audioPts.Value ? videoPts : audioPts; + } - try - { - return PtsAndDuration.From(lastLine); - } - catch (Exception ex) + return BaseError.New($"Failed to determine last pts duration for channel {parameters.ChannelNumber}"); + } + + private async Task> GetPts(RequestParameters parameters, FileInfo segment, string audioVideo, CancellationToken cancellationToken) + { + string[] argumentList = + { + "-v", "0", + "-select_streams", $"{audioVideo}:0", + "-show_entries", + "packet=pts_time,duration_time", + "-of", "compact=p=0:nk=1", + // "-read_intervals", "999999", // read_intervals causes inconsistent behavior on windows + segment.FullName + }; + + string lastLine = string.Empty; + Action replaceLine = s => + { + if (!string.IsNullOrWhiteSpace(s)) { - _client.Notify(ex); - await SaveTroubleshootingData(parameters.ChannelNumber, lastLine); + lastLine = s.Trim(); } + }; + + CommandResult probe = await Cli.Wrap(parameters.FFprobePath) + .WithArguments(argumentList) + .WithValidation(CommandResultValidation.None) + .WithStandardOutputPipe(PipeTarget.ToDelegate(replaceLine)) + .ExecuteAsync(cancellationToken); + + if (probe.ExitCode != 0) + { + return Option.None; } - return BaseError.New($"Failed to determine last pts duration for channel {parameters.ChannelNumber}"); + try + { + return PtsTime.From(lastLine); + } + catch (Exception ex) + { + _client.Notify(ex); + await SaveTroubleshootingData(parameters.ChannelNumber, lastLine); + } + + return Option.None; } private static Option GetLastSegment(string channelNumber) diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs index 5a1223296..8974f59e8 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs @@ -6,7 +6,7 @@ public record GetPlayoutItemProcessByChannelNumber( DateTimeOffset Now, bool StartAtZero, bool HlsRealtime, - long PtsOffset, + double PtsOffset, Option TargetFramerate) : FFmpegProcessRequest( ChannelNumber, Mode, diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 017bcf986..13c369fcb 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -70,7 +70,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService FillerKind fillerKind, TimeSpan inPoint, TimeSpan outPoint, - long ptsOffset, + double ptsOffset, Option targetFramerate, bool disableWatermarks, Option customReportsFolder, @@ -455,7 +455,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Option duration, string errorMessage, bool hlsRealtime, - long ptsOffset, + double ptsOffset, string vaapiDisplay, VaapiDriver vaapiDriver, string vaapiDevice, diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index 3e71ab954..83e7a237f 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -36,7 +36,7 @@ public interface IFFmpegProcessService FillerKind fillerKind, TimeSpan inPoint, TimeSpan outPoint, - long ptsOffset, + double ptsOffset, Option targetFramerate, bool disableWatermarks, Option customReportsFolder, @@ -48,7 +48,7 @@ public interface IFFmpegProcessService Option duration, string errorMessage, bool hlsRealtime, - long ptsOffset, + double ptsOffset, string vaapiDisplay, VaapiDriver vaapiDriver, string vaapiDevice, diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index 80421fd30..3aa5b6cd5 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -116,7 +116,7 @@ public class PipelineBuilderBaseTests string command = PrintCommand(videoInputFile, audioInputFile, None, None, result); command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[a];[0:0]setpts=PTS-STARTPTS[vpf] -map [vpf] -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); + "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1:first_pts=0,asetpts=N/SR/TB[a];[0:0]setpts=PTS-STARTPTS[vpf] -map [vpf] -map [a] -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); } [Test] @@ -213,7 +213,7 @@ public class PipelineBuilderBaseTests string command = PrintCommand(videoInputFile, audioInputFile, None, None, result); command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[a];[0:0]setpts=PTS-STARTPTS[vpf] -map [vpf] -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); + "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1:first_pts=0,asetpts=N/SR/TB[a];[0:0]setpts=PTS-STARTPTS[vpf] -map [vpf] -map [a] -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); } [Test] @@ -368,9 +368,9 @@ public class PipelineBuilderBaseTests string command = PrintCommand(videoInputFile, audioInputFile, None, None, result); - // 0.4.0 reference: "-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -ss 00:14:33.6195516 -i /tmp/whatever.mkv -map 0:0 -map 0:a -c:v copy -flags cgop -sc_threshold 0 -c:a copy -movflags +faststart -muxdelay 0 -muxpreload 0 -metadata service_provider="ErsatzTV" -metadata service_name="ErsatzTV" -t 00:06:39.6934484 -f mpegts -mpegts_flags +initial_discontinuity pipe:1" + // 0.4.0 reference: "-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -ss 00:14:33.6195516 -i /tmp/whatever.mkv -map 0:0 -map 0:a -c:v copy -flags cgop -sc_threshold 0 -c:a copy -movflags +faststart -metadata service_provider="ErsatzTV" -metadata service_name="ErsatzTV" -t 00:06:39.6934484 -f mpegts -mpegts_flags +initial_discontinuity pipe:1" command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1"); + "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1"); } [Test] @@ -459,7 +459,7 @@ public class PipelineBuilderBaseTests string command = PrintCommand(videoInputFile, audioInputFile, None, None, result); command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1"); + "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1"); } [Test] diff --git a/ErsatzTV.FFmpeg/FFmpegState.cs b/ErsatzTV.FFmpeg/FFmpegState.cs index 57ddb41b0..d78ef5db5 100644 --- a/ErsatzTV.FFmpeg/FFmpegState.cs +++ b/ErsatzTV.FFmpeg/FFmpegState.cs @@ -19,7 +19,7 @@ public record FFmpegState( OutputFormatKind OutputFormat, Option HlsPlaylistPath, Option HlsSegmentTemplate, - long PtsOffset, + double PtsOffset, Option ThreadCount, Option MaybeQsvExtraHardwareFrames, bool IsSongWithProgress, diff --git a/ErsatzTV.FFmpeg/Filter/AudioFirstPtsFilter.cs b/ErsatzTV.FFmpeg/Filter/AudioFirstPtsFilter.cs index 1b7bb8f5f..bbe081b57 100644 --- a/ErsatzTV.FFmpeg/Filter/AudioFirstPtsFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/AudioFirstPtsFilter.cs @@ -1,12 +1,8 @@ namespace ErsatzTV.FFmpeg.Filter; -public class AudioFirstPtsFilter : BaseFilter +public class AudioFirstPtsFilter(int pts) : BaseFilter { - private readonly int _pts; - - public AudioFirstPtsFilter(int pts) => _pts = pts; - - public override string Filter => $"aresample=async=1:first_pts={_pts}"; + public override string Filter => $"aresample=async=1:first_pts={pts}"; public override FrameState NextState(FrameState currentState) => currentState; } diff --git a/ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs b/ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs index 262b838f8..3a153ed67 100644 --- a/ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs @@ -1,8 +1,10 @@ -namespace ErsatzTV.FFmpeg.Filter; +using System.Globalization; -public class AudioPadFilter : BaseFilter +namespace ErsatzTV.FFmpeg.Filter; + +public class AudioPadFilter(TimeSpan audioDuration) : BaseFilter { - public override string Filter => "apad"; + public override string Filter => $"apad=whole_dur={audioDuration.TotalMilliseconds.ToString(NumberFormatInfo.InvariantInfo)}ms"; public override FrameState NextState(FrameState currentState) => currentState; } diff --git a/ErsatzTV.FFmpeg/Filter/AudioSetPtsFilter.cs b/ErsatzTV.FFmpeg/Filter/AudioSetPtsFilter.cs index 849922e61..f456dbf42 100644 --- a/ErsatzTV.FFmpeg/Filter/AudioSetPtsFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/AudioSetPtsFilter.cs @@ -2,7 +2,7 @@ namespace ErsatzTV.FFmpeg.Filter; public class AudioSetPtsFilter : BaseFilter { - public override string Filter => "asetpts=PTS-STARTPTS"; + public override string Filter => "asetpts=N/SR/TB"; public override FrameState NextState(FrameState currentState) => currentState; } diff --git a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs index 5fb28651f..ebd04b288 100644 --- a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs +++ b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs @@ -90,20 +90,22 @@ public class OutputFormatHls : IPipelineStep foreach (string rFrameRate in _mediaFrameRate) { - if (!int.TryParse(rFrameRate, out int fr)) + 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)) { - fr = (int)Math.Round(left / (double)right); + frameRate = (int)Math.Round(left / (double)right); } else { - fr = 24; + frameRate = 24; } } - - frameRate = fr; } return frameRate; diff --git a/ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs b/ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs index 0289e966d..e1da8751e 100644 --- a/ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs +++ b/ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs @@ -2,22 +2,13 @@ namespace ErsatzTV.FFmpeg.OutputOption; -public class OutputTsOffsetOption : OutputOption +public class OutputTsOffsetOption(double ptsOffset) : OutputOption { - private readonly long _ptsOffset; - private readonly int _videoTrackTimeScale; - - public OutputTsOffsetOption(long ptsOffset, int videoTrackTimeScale) - { - _ptsOffset = ptsOffset; - _videoTrackTimeScale = videoTrackTimeScale; - } - - public override string[] OutputOptions => new[] - { + public override string[] OutputOptions => + [ "-output_ts_offset", - $"{(_ptsOffset / (double)_videoTrackTimeScale).ToString(NumberFormatInfo.InvariantInfo)}" - }; + $"{ptsOffset.ToString(NumberFormatInfo.InvariantInfo)}s" + ]; // public override FrameState NextState(FrameState currentState) => currentState with { PtsOffset = _ptsOffset }; } diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index 559c7f99b..ebf0ab3a4 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -169,7 +169,6 @@ public abstract class PipelineBuilderBase : IPipelineBuilder new NoStatsOption(), new LoglevelErrorOption(), new StandardFormatFlags(), - new NoDemuxDecodeDelayOutputOption(), outputOption, new ClosedGopOutputOption() }; @@ -205,7 +204,6 @@ public abstract class PipelineBuilderBase : IPipelineBuilder SetSceneDetect(videoStream, ffmpegState, desiredState, pipelineSteps); SetFFReport(ffmpegState, pipelineSteps); SetStreamSeek(ffmpegState, videoInputFile, context, pipelineSteps); - SetTimeLimit(ffmpegState, pipelineSteps); (FilterChain filterChain, ffmpegState) = BuildVideoPipeline( videoInputFile, @@ -420,16 +418,13 @@ public abstract class PipelineBuilderBase : IPipelineBuilder if (pipelineSteps.All(ps => ps is not EncoderCopyAudio)) { audioInputFile.FilterSteps.Add(new AudioFirstPtsFilter(0)); + audioInputFile.FilterSteps.Add(new AudioSetPtsFilter()); } - foreach (TimeSpan _ in audioInputFile.DesiredState.AudioDuration) - { - audioInputFile.FilterSteps.Add(new AudioPadFilter()); - } - - if (pipelineSteps.All(ps => ps is not EncoderCopyAudio)) + foreach (TimeSpan audioDuration in audioInputFile.DesiredState.AudioDuration) { - audioInputFile.FilterSteps.Add(new AudioSetPtsFilter()); + audioInputFile.FilterSteps.Add(new AudioPadFilter(audioDuration)); + pipelineSteps.Add(new ShortestOutputOption()); } } @@ -535,6 +530,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder SetVideoBufferSizeOutput(desiredState, pipelineSteps); } + + FilterChain filterChain = SetVideoFilters( videoInputFile, videoStream, @@ -636,10 +633,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder if (ffmpegState.PtsOffset > 0) { - foreach (int videoTrackTimeScale in desiredState.VideoTrackTimeScale) - { - pipelineSteps.Add(new OutputTsOffsetOption(ffmpegState.PtsOffset, videoTrackTimeScale)); - } + pipelineSteps.Add(new OutputTsOffsetOption(ffmpegState.PtsOffset)); } } @@ -835,8 +829,5 @@ public abstract class PipelineBuilderBase : IPipelineBuilder } } - private static void SetTimeLimit(FFmpegState ffmpegState, List pipelineSteps) => - pipelineSteps.AddRange(ffmpegState.Finish.Map(finish => new TimeLimitOutputOption(finish))); - private sealed record FilterChainAndState(FilterChain FilterChain, FFmpegState FFmpegState); }