switch back from fmp4 to ts segments (#2554)

* restore pts offset calculation

* use ts segments again

* update changelog
This commit is contained in:
Jason Dove
2025-10-21 12:17:05 -05:00
committed by GitHub
parent fcce53a3df
commit 2ef2b0299a
26 changed files with 333 additions and 55 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Rename `YAML Validation` tool to `Sequential Schedule Validation` - Rename `YAML Validation` tool to `Sequential Schedule Validation`
- Greatly reduce debug log spam during playout builds by logging summaries of certain warnings at the end - Greatly reduce debug log spam during playout builds by logging summaries of certain warnings at the end
- Remove *experimental* `HLS Segmenter V2` streaming mode; it is not possible to maintain quality output using this mode - Remove *experimental* `HLS Segmenter V2` streaming mode; it is not possible to maintain quality output using this mode
- Remove original `HLS Segmenter` streaming mode and rename `HLS Segmenter (fmp4)` to `HLS Segmenter` - Remove *experimental* `HLS Segmenter (fmp4)` streaming mode; this mode only worked properly in a browser, many clients did not like it
## [25.7.1] - 2025-10-09 ## [25.7.1] - 2025-10-09
### Added ### Added
@@ -11,6 +11,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.OutputFormat;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -124,6 +125,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_serviceScopeFactory, _serviceScopeFactory,
_graphicsEngine, _graphicsEngine,
_client, _client,
OutputFormatKind.Hls,
_hlsPlaylistFilter, _hlsPlaylistFilter,
_hlsInitSegmentCache, _hlsInitSegmentCache,
_configElementRepository, _configElementRepository,
@@ -26,6 +26,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
private static int _workAheadCount; private static int _workAheadCount;
private readonly IClient _client; private readonly IClient _client;
private readonly OutputFormatKind _outputFormatKind;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache; private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = []; private readonly Dictionary<long, int> _discontinuityMap = [];
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
@@ -42,6 +43,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private DateTimeOffset _channelStart; private DateTimeOffset _channelStart;
private int _discontinuitySequence; private int _discontinuitySequence;
private bool _disposedValue; private bool _disposedValue;
private bool _hasWrittenSegments;
private DateTimeOffset _lastAccess; private DateTimeOffset _lastAccess;
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue; private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
private IServiceScope _serviceScope; private IServiceScope _serviceScope;
@@ -54,6 +56,7 @@ public class HlsSessionWorker : IHlsSessionWorker
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
IGraphicsEngine graphicsEngine, IGraphicsEngine graphicsEngine,
IClient client, IClient client,
OutputFormatKind outputFormatKind,
IHlsPlaylistFilter hlsPlaylistFilter, IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache, IHlsInitSegmentCache hlsInitSegmentCache,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
@@ -65,6 +68,7 @@ public class HlsSessionWorker : IHlsSessionWorker
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>(); _mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_graphicsEngine = graphicsEngine; _graphicsEngine = graphicsEngine;
_client = client; _client = client;
_outputFormatKind = outputFormatKind;
_hlsInitSegmentCache = hlsInitSegmentCache; _hlsInitSegmentCache = hlsInitSegmentCache;
_hlsPlaylistFilter = hlsPlaylistFilter; _hlsPlaylistFilter = hlsPlaylistFilter;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
@@ -120,7 +124,7 @@ public class HlsSessionWorker : IHlsSessionWorker
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
_discontinuityMap, _discontinuityMap,
OutputFormatKind.HlsMp4, _outputFormatKind,
PlaylistStart, PlaylistStart,
filterBefore, filterBefore,
_hlsInitSegmentCache, _hlsInitSegmentCache,
@@ -442,8 +446,7 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
} }
// fmp4 doesn't require pts offsets because each new item gets a discontinuity AND a new init segment TimeSpan ptsOffset = await GetPtsOffset(_channelNumber, cancellationToken);
const long PTS_OFFSET = 0;
_logger.LogDebug("HLS session state: {State}", _state); _logger.LogDebug("HLS session state: {State}", _state);
@@ -457,7 +460,7 @@ public class HlsSessionWorker : IHlsSessionWorker
startAtZero, startAtZero,
realtime, realtime,
_channelStart, _channelStart,
PTS_OFFSET, ptsOffset,
_targetFramerate); _targetFramerate);
// _logger.LogInformation("Request {@Request}", request); // _logger.LogInformation("Request {@Request}", request);
@@ -528,6 +531,7 @@ public class HlsSessionWorker : IHlsSessionWorker
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds); processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
_transcodedUntil = processModel.Until; _transcodedUntil = processModel.Until;
_state = NextState(_state, processModel); _state = NextState(_state, processModel);
_hasWrittenSegments = true;
return true; return true;
} }
else else
@@ -552,7 +556,7 @@ public class HlsSessionWorker : IHlsSessionWorker
_channelNumber, _channelNumber,
StreamingMode.HttpLiveStreamingSegmenter, StreamingMode.HttpLiveStreamingSegmenter,
realtime, realtime,
PTS_OFFSET, ptsOffset,
processModel.MaybeDuration, processModel.MaybeDuration,
processModel.Until, processModel.Until,
errorMessage), errorMessage),
@@ -577,6 +581,8 @@ public class HlsSessionWorker : IHlsSessionWorker
_transcodedUntil = processModel.Until; _transcodedUntil = processModel.Until;
_state = NextState(_state, null); _state = NextState(_state, null);
_hasWrittenSegments = true;
return true; return true;
} }
} }
@@ -648,7 +654,7 @@ public class HlsSessionWorker : IHlsSessionWorker
// trim playlist and insert discontinuity before appending with new ffmpeg process // trim playlist and insert discontinuity before appending with new ffmpeg process
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_discontinuityMap, _discontinuityMap,
OutputFormatKind.HlsMp4, _outputFormatKind,
PlaylistStart, PlaylistStart,
DateTimeOffset.Now.AddMinutes(-1), DateTimeOffset.Now.AddMinutes(-1),
_hlsInitSegmentCache, _hlsInitSegmentCache,
@@ -759,6 +765,44 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
} }
private async Task<TimeSpan> GetPtsOffset(string channelNumber, CancellationToken cancellationToken)
{
await _slim.WaitAsync(cancellationToken);
try
{
TimeSpan result = TimeSpan.Zero;
// if we haven't yet written any segments, start at zero
if (!_hasWrittenSegments)
{
return result;
}
await RefreshInits();
Either<BaseError, PtsTime> queryResult = await _mediator.Send(
new GetLastPtsTime(_hlsInitSegmentCache, channelNumber),
cancellationToken);
foreach (BaseError error in queryResult.LeftToSeq())
{
_logger.LogWarning("Unable to determine last pts offset - {Error}", error.ToString());
}
foreach (PtsTime pts in queryResult.RightToSeq())
{
_logger.LogWarning("Last pts offset is {Pts}", pts.Value);
result = pts.Value;
}
return result;
}
finally
{
_slim.Release();
}
}
private async Task<int> GetWorkAheadLimit(CancellationToken cancellationToken) => private async Task<int> GetWorkAheadLimit(CancellationToken cancellationToken) =>
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken) await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken)
.Map(maybeCount => maybeCount.Match(identity, () => 1)); .Map(maybeCount => maybeCount.Match(identity, () => 1));
+5 -5
View File
@@ -2,19 +2,19 @@
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public record PtsTime(long Value) public record PtsTime(TimeSpan Value)
{ {
public static readonly PtsTime Zero = new(0); public static readonly PtsTime Zero = new(TimeSpan.Zero);
public static PtsTime From(string ffprobeLine) public static PtsTime From(string ffprobeLine)
{ {
string[] split = ffprobeLine.Split("|"); string[] split = ffprobeLine.Split("|");
var ptsTime = long.Parse(split[0], CultureInfo.InvariantCulture); var ptsTime = double.Parse(split[0], CultureInfo.InvariantCulture);
if (long.TryParse(split[1], CultureInfo.InvariantCulture, out long duration)) if (double.TryParse(split[1], CultureInfo.InvariantCulture, out double duration))
{ {
ptsTime += duration; ptsTime += duration;
} }
return new PtsTime(ptsTime); return new PtsTime(TimeSpan.FromSeconds(ptsTime));
} }
} }
@@ -10,4 +10,4 @@ public record FFmpegProcessRequest(
bool StartAtZero, bool StartAtZero,
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStartTime, DateTimeOffset ChannelStartTime,
long PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>; TimeSpan PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
@@ -11,7 +11,7 @@ public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
false, false,
true, true,
DateTimeOffset.Now, // unused DateTimeOffset.Now, // unused
0) TimeSpan.Zero)
{ {
Scheme = scheme; Scheme = scheme;
Host = host; Host = host;
@@ -6,7 +6,7 @@ public record GetErrorProcess(
string ChannelNumber, string ChannelNumber,
StreamingMode Mode, StreamingMode Mode,
bool HlsRealtime, bool HlsRealtime,
long PtsOffset, TimeSpan PtsOffset,
Option<TimeSpan> MaybeDuration, Option<TimeSpan> MaybeDuration,
DateTimeOffset Until, DateTimeOffset Until,
string ErrorMessage) : FFmpegProcessRequest( string ErrorMessage) : FFmpegProcessRequest(
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.Streaming;
public record GetLastPtsTime(IHlsInitSegmentCache InitSegmentCache, string ChannelNumber)
: IRequest<Either<BaseError, PtsTime>>;
@@ -0,0 +1,211 @@
using System.Text;
using Bugsnag;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace ErsatzTV.Application.Streaming;
public class GetLastPtsTimeHandler(
IClient client,
ILocalFileSystem localFileSystem,
ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository,
ILogger<GetLastPtsTimeHandler> logger)
: IRequestHandler<GetLastPtsTime, Either<BaseError, PtsTime>>
{
public async Task<Either<BaseError, PtsTime>> Handle(
GetLastPtsTime request,
CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request, cancellationToken);
return await validation.Match(
parameters => Handle(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, PtsTime>>(error.Join()));
}
private async Task<Validation<BaseError, RequestParameters>> Validate(
GetLastPtsTime request,
CancellationToken cancellationToken) =>
await ValidateFFprobePath(cancellationToken)
.MapT(ffprobePath => new RequestParameters(request.InitSegmentCache, request.ChannelNumber, ffprobePath));
private async Task<Either<BaseError, PtsTime>> Handle(
RequestParameters parameters,
CancellationToken cancellationToken)
{
Option<FileInfo> maybeLastSegment = GetLastSegment(parameters);
foreach (FileInfo segment in maybeLastSegment)
{
return await GetPts(parameters, segment, cancellationToken).IfNoneAsync(PtsTime.Zero);
}
return BaseError.New($"Failed to determine last pts duration for channel {parameters.ChannelNumber}");
}
private async Task<Option<PtsTime>> GetPts(
RequestParameters parameters,
FileInfo segment,
CancellationToken cancellationToken)
{
string[] argumentList =
{
"-v", "0",
"-show_entries",
"packet=pts_time,duration_time",
"-of", "compact=p=0:nk=1",
segment.FullName
};
PtsTime maxTime = PtsTime.Zero;
Action<string> replaceLine = s =>
{
if (!string.IsNullOrWhiteSpace(s))
{
try
{
var newPts = PtsTime.From(s.Trim());
if (newPts.Value > maxTime.Value)
{
maxTime = newPts;
}
}
catch
{
// do nothing
}
}
};
logger.LogDebug("ffprobe arguments {FFmpegArguments}", argumentList.ToList());
CommandResult probe = await Cli.Wrap(parameters.FFprobePath)
.WithArguments(argumentList)
.WithValidation(CommandResultValidation.None)
.WithStandardOutputPipe(PipeTarget.ToDelegate(replaceLine))
.ExecuteAsync(cancellationToken);
if (probe.ExitCode != 0)
{
return Option<PtsTime>.None;
}
return maxTime;
}
private Option<FileInfo> GetLastSegment(RequestParameters parameters)
{
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, parameters.ChannelNumber));
var allFiles = directory.GetFiles("*.ts").Append(directory.GetFiles("*.m4s")).ToList();
Option<FileInfo> maybeLastSegment = Optional(allFiles.OrderByDescending(f => f.Name).FirstOrDefault());
foreach (var lastSegment in maybeLastSegment)
{
if (lastSegment.Name.Contains("m4s"))
{
string[] split = lastSegment.Name.Split('_');
if (long.TryParse(split[1], out long generatedAt))
{
try
{
string init = parameters.InitSegmentCache.EarliestSegmentByHash(generatedAt);
string fullInit = Path.Combine(directory.FullName, init);
string combined = tempFilePool.GetNextTempFile(TempFileCategory.Fmp4LastSegment);
using (var output = File.OpenWrite(combined))
{
// copy init
using (var readInit = File.OpenRead(fullInit))
{
readInit.CopyTo(output);
}
// copy segment
using (var readSegment = File.OpenRead(lastSegment.FullName))
{
readSegment.CopyTo(output);
}
}
// return concatenated init + segment
return new FileInfo(combined);
}
catch (Exception e)
{
Console.WriteLine(e);
Console.WriteLine($"Can't find init for last segment {lastSegment.FullName}");
foreach (var file in allFiles)
{
Console.WriteLine(file.FullName);
}
throw;
}
}
}
return lastSegment;
}
return Option<FileInfo>.None;
}
private Task<Validation<BaseError, string>> ValidateFFprobePath(CancellationToken cancellationToken) =>
configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
.FilterT(File.Exists)
.Map(ffprobePath => ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private async Task SaveTroubleshootingData(string channelNumber, string output)
{
try
{
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
FileInfo[] allFiles = directory.GetFiles();
string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
string playlistContents = string.Empty;
if (localFileSystem.FileExists(playlistFileName))
{
playlistContents = await File.ReadAllTextAsync(playlistFileName);
}
var data = new TroubleshootingData(allFiles, playlistContents, output);
string serialized = data.Serialize();
string file = tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder);
await File.WriteAllTextAsync(file, serialized);
logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
}
catch (Exception ex)
{
client.Notify(ex);
}
}
private sealed record RequestParameters(
IHlsInitSegmentCache InitSegmentCache,
string ChannelNumber,
string FFprobePath);
private sealed record TroubleshootingData(IEnumerable<FileInfo> Files, string Playlist, string ProbeOutput)
{
public string Serialize()
{
var data = new InternalData(
Files.Map(f => new FileData(f.FullName, f.Length, f.LastWriteTimeUtc)).ToList(),
Convert.ToBase64String(Encoding.UTF8.GetBytes(Playlist)),
Convert.ToBase64String(Encoding.UTF8.GetBytes(ProbeOutput)));
return JsonConvert.SerializeObject(data);
}
private sealed record FileData(string FileName, long Bytes, DateTime LastWriteTimeUtc);
private sealed record InternalData(List<FileData> Files, string EncodedPlaylist, string EncodedProbeOutput);
}
}
@@ -9,7 +9,7 @@ public record GetPlayoutItemProcessByChannelNumber(
bool StartAtZero, bool StartAtZero,
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStart, DateTimeOffset ChannelStart,
long PtsOffset, TimeSpan PtsOffset,
Option<int> TargetFramerate) : FFmpegProcessRequest( Option<int> TargetFramerate) : FFmpegProcessRequest(
ChannelNumber, ChannelNumber,
Mode, Mode,
@@ -15,7 +15,7 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
false, false,
true, true,
DateTimeOffset.Now, // unused DateTimeOffset.Now, // unused
0) TimeSpan.Zero)
{ {
Scheme = scheme; Scheme = scheme;
Host = host; Host = host;
@@ -247,7 +247,7 @@ public class PrepareTroubleshootingPlaybackHandler(
FillerKind.None, FillerKind.None,
inPoint, inPoint,
channelStartTime: DateTimeOffset.Now, channelStartTime: DateTimeOffset.Now,
0, TimeSpan.Zero,
None, None,
FileSystemLayout.TranscodeTroubleshootingFolder, FileSystemLayout.TranscodeTroubleshootingFolder,
_ => { }, _ => { },
@@ -82,7 +82,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FillerKind fillerKind, FillerKind fillerKind,
TimeSpan inPoint, TimeSpan inPoint,
DateTimeOffset channelStartTime, DateTimeOffset channelStartTime,
long ptsOffset, TimeSpan ptsOffset,
Option<int> targetFramerate, Option<int> targetFramerate,
Option<string> customReportsFolder, Option<string> customReportsFolder,
Action<FFmpegPipeline> pipelineAction, Action<FFmpegPipeline> pipelineAction,
@@ -244,7 +244,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
switch (channel.StreamingMode) switch (channel.StreamingMode)
{ {
case StreamingMode.HttpLiveStreamingSegmenter: case StreamingMode.HttpLiveStreamingSegmenter:
outputFormat = OutputFormatKind.HlsMp4; outputFormat = OutputFormatKind.Hls;
break; break;
case StreamingMode.HttpLiveStreamingDirect: case StreamingMode.HttpLiveStreamingDirect:
{ {
@@ -554,7 +554,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<TimeSpan> duration, Option<TimeSpan> duration,
string errorMessage, string errorMessage,
bool hlsRealtime, bool hlsRealtime,
long ptsOffset, TimeSpan ptsOffset,
string vaapiDisplay, string vaapiDisplay,
VaapiDriver vaapiDriver, VaapiDriver vaapiDriver,
string vaapiDevice, string vaapiDevice,
@@ -619,7 +619,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
switch (channel.StreamingMode) switch (channel.StreamingMode)
{ {
case StreamingMode.HttpLiveStreamingSegmenter: case StreamingMode.HttpLiveStreamingSegmenter:
outputFormat = OutputFormatKind.HlsMp4; outputFormat = OutputFormatKind.Hls;
break; break;
} }
@@ -631,6 +631,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string> hlsSegmentTemplate = outputFormat switch Option<string> hlsSegmentTemplate = outputFormat switch
{ {
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
OutputFormatKind.HlsMp4 => Path.Combine( OutputFormatKind.HlsMp4 => Path.Combine(
FileSystemLayout.TranscodeFolder, FileSystemLayout.TranscodeFolder,
channel.Number, channel.Number,
+1
View File
@@ -7,6 +7,7 @@ public enum TempFileCategory
CoverArt = 2, CoverArt = 2,
CachedArtwork = 3, CachedArtwork = 3,
Fmp4LastSegment = 97,
BadTranscodeFolder = 98, BadTranscodeFolder = 98,
BadPlaylist = 99 BadPlaylist = 99
} }
@@ -37,7 +37,7 @@ public interface IFFmpegProcessService
FillerKind fillerKind, FillerKind fillerKind,
TimeSpan inPoint, TimeSpan inPoint,
DateTimeOffset channelStartTime, DateTimeOffset channelStartTime,
long ptsOffset, TimeSpan ptsOffset,
Option<int> targetFramerate, Option<int> targetFramerate,
Option<string> customReportsFolder, Option<string> customReportsFolder,
Action<FFmpegPipeline> pipelineAction, Action<FFmpegPipeline> pipelineAction,
@@ -50,7 +50,7 @@ public interface IFFmpegProcessService
Option<TimeSpan> duration, Option<TimeSpan> duration,
string errorMessage, string errorMessage,
bool hlsRealtime, bool hlsRealtime,
long ptsOffset, TimeSpan ptsOffset,
string vaapiDisplay, string vaapiDisplay,
VaapiDriver vaapiDriver, VaapiDriver vaapiDriver,
string vaapiDevice, string vaapiDevice,
@@ -91,7 +91,7 @@ public class PipelineBuilderBaseTests
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
0, TimeSpan.Zero,
Option<int>.None, Option<int>.None,
Option<int>.None, Option<int>.None,
false, false,
@@ -190,7 +190,7 @@ public class PipelineBuilderBaseTests
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
0, TimeSpan.Zero,
Option<int>.None, Option<int>.None,
Option<int>.None, Option<int>.None,
false, false,
@@ -347,7 +347,7 @@ public class PipelineBuilderBaseTests
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
0, TimeSpan.Zero,
Option<int>.None, Option<int>.None,
Option<int>.None, Option<int>.None,
false, false,
@@ -440,7 +440,7 @@ public class PipelineBuilderBaseTests
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
0, TimeSpan.Zero,
Option<int>.None, Option<int>.None,
Option<int>.None, Option<int>.None,
false, false,
+2 -2
View File
@@ -20,7 +20,7 @@ public record FFmpegState(
Option<string> HlsPlaylistPath, Option<string> HlsPlaylistPath,
Option<string> HlsSegmentTemplate, Option<string> HlsSegmentTemplate,
Option<string> HlsInitTemplate, Option<string> HlsInitTemplate,
long PtsOffset, TimeSpan PtsOffset,
Option<int> ThreadCount, Option<int> ThreadCount,
Option<int> MaybeQsvExtraHardwareFrames, Option<int> MaybeQsvExtraHardwareFrames,
bool IsSongWithProgress, bool IsSongWithProgress,
@@ -49,7 +49,7 @@ public record FFmpegState(
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
Option<string>.None, Option<string>.None,
0, TimeSpan.Zero,
Option<int>.None, Option<int>.None,
Option<int>.None, Option<int>.None,
false, false,
@@ -1,6 +1,6 @@
namespace ErsatzTV.FFmpeg.Filter.Qsv; namespace ErsatzTV.FFmpeg.Filter;
public class QsvResetPtsFilter : BaseFilter public class ResetPtsFilter : BaseFilter
{ {
public override string Filter => "setpts=PTS-STARTPTS"; public override string Filter => "setpts=PTS-STARTPTS";
@@ -95,12 +95,24 @@ public class OutputFormatHls : IPipelineStep
} }
else else
{ {
result.AddRange( switch (_outputFormat)
[ {
"-hls_flags", $"{pdt}append_list+discont_start{independentSegments}", case OutputFormatKind.HlsMp4:
"-mpegts_flags", "+initial_discontinuity", result.AddRange(
_playlistPath [
]); "-hls_flags", $"{pdt}append_list+discont_start{independentSegments}",
_playlistPath
]);
break;
default:
result.AddRange(
[
"-hls_flags", $"{pdt}append_list+discont_start{independentSegments}",
"-mpegts_flags", "+initial_discontinuity",
_playlistPath
]);
break;
}
} }
return result.ToArray(); return result.ToArray();
@@ -2,12 +2,12 @@
namespace ErsatzTV.FFmpeg.OutputOption; namespace ErsatzTV.FFmpeg.OutputOption;
public class OutputTsOffsetOption(long ptsOffset, int videoTrackTimeScale) : OutputOption public class OutputTsOffsetOption(TimeSpan ptsOffset) : OutputOption
{ {
public override string[] OutputOptions => public override string[] OutputOptions =>
[ [
"-output_ts_offset", "-output_ts_offset",
$"{(ptsOffset / (double)videoTrackTimeScale).ToString(NumberFormatInfo.InvariantInfo)}" $"{ptsOffset.TotalSeconds.ToString(NumberFormatInfo.InvariantInfo)}s"
]; ];
// public override FrameState NextState(FrameState currentState) => currentState with { PtsOffset = _ptsOffset }; // public override FrameState NextState(FrameState currentState) => currentState with { PtsOffset = _ptsOffset };
@@ -255,6 +255,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
SetTimeLimit(ffmpegState, pipelineSteps); SetTimeLimit(ffmpegState, pipelineSteps);
(FilterChain filterChain, ffmpegState) = BuildVideoPipeline( (FilterChain filterChain, ffmpegState) = BuildVideoPipeline(
isFmp4Hls,
videoInputFile, videoInputFile,
videoStream, videoStream,
ffmpegState, ffmpegState,
@@ -386,7 +387,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
segmentTemplate, segmentTemplate,
ffmpegState.HlsInitTemplate, ffmpegState.HlsInitTemplate,
playlistPath, playlistPath,
ffmpegState.PtsOffset == 0, ffmpegState.PtsOffset == TimeSpan.Zero,
oneSecondGop, oneSecondGop,
ffmpegState.IsTroubleshooting)); ffmpegState.IsTroubleshooting));
} }
@@ -539,6 +540,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
ICollection<IPipelineStep> pipelineSteps); ICollection<IPipelineStep> pipelineSteps);
private FilterChainAndState BuildVideoPipeline( private FilterChainAndState BuildVideoPipeline(
bool isFmp4Hls,
VideoInputFile videoInputFile, VideoInputFile videoInputFile,
VideoStream videoStream, VideoStream videoStream,
FFmpegState ffmpegState, FFmpegState ffmpegState,
@@ -593,7 +595,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
_fontsFolder, _fontsFolder,
pipelineSteps); pipelineSteps);
SetOutputTsOffset(ffmpegState, desiredState, pipelineSteps); SetOutputTsOffset(isFmp4Hls, ffmpegState, desiredState, pipelineSteps);
return new FilterChainAndState(filterChain, ffmpegState); return new FilterChainAndState(filterChain, ffmpegState);
} }
@@ -674,6 +676,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
} }
private static void SetOutputTsOffset( private static void SetOutputTsOffset(
bool isFmp4Hls,
FFmpegState ffmpegState, FFmpegState ffmpegState,
FrameState desiredState, FrameState desiredState,
List<IPipelineStep> pipelineSteps) List<IPipelineStep> pipelineSteps)
@@ -683,12 +686,9 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
return; return;
} }
if (ffmpegState.PtsOffset > 0) if (ffmpegState.PtsOffset > TimeSpan.Zero && !isFmp4Hls)
{ {
foreach (int videoTrackTimeScale in desiredState.VideoTrackTimeScale) pipelineSteps.Add(new OutputTsOffsetOption(ffmpegState.PtsOffset));
{
pipelineSteps.Add(new OutputTsOffsetOption(ffmpegState.PtsOffset, videoTrackTimeScale));
}
} }
} }
@@ -170,7 +170,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
currentState = decoder.NextState(currentState); currentState = decoder.NextState(currentState);
} }
videoInputFile.FilterSteps.Add(new QsvResetPtsFilter()); videoInputFile.FilterSteps.Add(new ResetPtsFilter());
// easier to use nv12 for overlay // easier to use nv12 for overlay
if (context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine) if (context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine)
@@ -396,7 +396,7 @@ public class TranscodingTests
FillerKind.None, FillerKind.None,
TimeSpan.Zero, TimeSpan.Zero,
DateTimeOffset.Now, DateTimeOffset.Now,
0, TimeSpan.Zero,
None, None,
Option<string>.None, Option<string>.None,
_ => { }, _ => { },
@@ -707,7 +707,7 @@ public class TranscodingTests
FillerKind.None, FillerKind.None,
TimeSpan.Zero, TimeSpan.Zero,
DateTimeOffset.Now, DateTimeOffset.Now,
0, TimeSpan.Zero,
None, None,
Option<string>.None, Option<string>.None,
PipelineAction, PipelineAction,
@@ -119,9 +119,9 @@ public class TroubleshootController(
string[] segmentFiles = streamingMode switch string[] segmentFiles = streamingMode switch
{ {
StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles( // StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles(
FileSystemLayout.TranscodeTroubleshootingFolder, // FileSystemLayout.TranscodeTroubleshootingFolder,
"*.m4s"), // "*.m4s"),
_ => Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "*.ts") _ => Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "*.ts")
}; };
+1 -1
View File
@@ -255,7 +255,7 @@ public class InternalController : StreamingControllerBase
false, false,
true, true,
DateTimeOffset.Now, DateTimeOffset.Now,
0, TimeSpan.Zero,
Option<int>.None); Option<int>.None);
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request); Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
+1 -1
View File
@@ -342,7 +342,7 @@ public class IptvController : StreamingControllerBase
false, false,
true, true,
DateTimeOffset.Now, DateTimeOffset.Now,
0, TimeSpan.Zero,
Option<int>.None); Option<int>.None);
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request); Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);