Browse Source

burn in picture-based subtitles (#718)

* add subtitle mode setting

* start to add subtitle support

* cuda test

* move subtitle settings from ffmpeg profile to channel

* fix image-based subtitles

* experimental wip

* subtitle fixes
pull/719/head
Jason Dove 4 years ago committed by GitHub
parent
commit
df45b93819
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      CHANGELOG.md
  2. 6
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  3. 6
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  4. 31
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  5. 6
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  6. 12
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  7. 8
      ErsatzTV.Application/Channels/Mapper.cs
  8. 2
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  9. 2
      ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs
  10. 2
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs
  11. 4
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs
  12. 3
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  13. 4
      ErsatzTV.Core/Domain/Channel.cs
  14. 9
      ErsatzTV.Core/Domain/ChannelSubtitleMode.cs
  15. 30
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  16. 82
      ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
  17. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs
  18. 6
      ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs
  19. 13
      ErsatzTV.FFmpeg/Encoder/EncoderBase.cs
  20. 8
      ErsatzTV.FFmpeg/Encoder/EncoderCopySubtitle.cs
  21. 15
      ErsatzTV.FFmpeg/Filter/AvailableSubtitleOverlayFilters.cs
  22. 8
      ErsatzTV.FFmpeg/Filter/AvailableWatermarkOverlayFilters.cs
  23. 87
      ErsatzTV.FFmpeg/Filter/ComplexFilter.cs
  24. 8
      ErsatzTV.FFmpeg/Filter/Cuda/OverlaySubtitleCudaFilter.cs
  25. 4
      ErsatzTV.FFmpeg/Filter/Cuda/OverlayWatermarkCudaFilter.cs
  26. 8
      ErsatzTV.FFmpeg/Filter/OverlaySubtitleFilter.cs
  27. 4
      ErsatzTV.FFmpeg/Filter/OverlayWatermarkFilter.cs
  28. 8
      ErsatzTV.FFmpeg/Filter/Qsv/OverlaySubtitleQsvFilter.cs
  29. 4
      ErsatzTV.FFmpeg/Filter/Qsv/OverlayWatermarkQsvFilter.cs
  30. 32
      ErsatzTV.FFmpeg/Filter/SubtitleHardwareUploadFilter.cs
  31. 28
      ErsatzTV.FFmpeg/Filter/SubtitlePixelFormatFilter.cs
  32. 5
      ErsatzTV.FFmpeg/InputFile.cs
  33. 47
      ErsatzTV.FFmpeg/PipelineBuilder.cs
  34. 1
      ErsatzTV.FFmpeg/StreamKind.cs
  35. 4
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  36. 3885
      ErsatzTV.Infrastructure/Migrations/20220329005536_Add_FFmpegProfileSubtitleMode.Designer.cs
  37. 47
      ErsatzTV.Infrastructure/Migrations/20220329005536_Add_FFmpegProfileSubtitleMode.cs
  38. 3888
      ErsatzTV.Infrastructure/Migrations/20220330162314_Remove_FFmpegProfileSubtitleMode.Designer.cs
  39. 57
      ErsatzTV.Infrastructure/Migrations/20220330162314_Remove_FFmpegProfileSubtitleMode.cs
  40. 10
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  41. 6
      ErsatzTV/Controllers/IptvController.cs
  42. 27
      ErsatzTV/Pages/ChannelEditor.razor
  43. 6
      ErsatzTV/Pages/Channels.razor
  44. 2
      ErsatzTV/Pages/Settings.razor
  45. 6
      ErsatzTV/Validators/ChannelEditViewModelValidator.cs
  46. 16
      ErsatzTV/ViewModels/ChannelEditViewModel.cs

8
CHANGELOG.md

@ -4,8 +4,16 @@ All notable changes to this project will be documented in this file. @@ -4,8 +4,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add `Preferred Subtitle Language` and `Subtitle Mode` to channel settings
- `Preferred Subtitle Language` will filter all subtitle streams based on language
- `Subtitle Mode` will further filter subtitle streams based on attributes (forced, default)
- If picture-based subtitles are found after filtering, they will be burned into the video stream
### Changed
- Remove legacy transcoder logic option; all channels will use the new transcoder logic
- Renamed channel setting `Preferred Language` to `Preferred Audio Language`
## [0.4.5-alpha] - 2022-03-29
### Fixed

6
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -10,8 +10,10 @@ public record ChannelViewModel( @@ -10,8 +10,10 @@ public record ChannelViewModel(
string Categories,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
string PreferredAudioLanguageCode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
int PlayoutCount);
int PlayoutCount,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode);

6
ErsatzTV.Application/Channels/Commands/CreateChannel.cs

@ -11,7 +11,9 @@ public record CreateChannel @@ -11,7 +11,9 @@ public record CreateChannel
string Categories,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
string PreferredAudioLanguageCode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, CreateChannelResult>>;
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, CreateChannelResult>>;

31
ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs

@ -21,7 +21,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr @@ -21,7 +21,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => PersistChannel(dbContext, c));
return await validation.Apply(c => PersistChannel(dbContext, c));
}
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
@ -34,11 +34,19 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr @@ -34,11 +34,19 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
(ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request),
ValidatePreferredLanguage(request),
ValidatePreferredAudioLanguage(request),
ValidatePreferredSubtitleLanguage(request),
await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
.Apply(
(name, number, ffmpegProfileId, preferredLanguageCode, watermarkId, fillerPresetId) =>
(
name,
number,
ffmpegProfileId,
preferredAudioLanguageCode,
preferredSubtitleLanguageCode,
watermarkId,
fillerPresetId) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
@ -62,7 +70,9 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr @@ -62,7 +70,9 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode,
Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode
PreferredAudioLanguageCode = preferredAudioLanguageCode,
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode
};
foreach (int id in watermarkId)
@ -82,12 +92,19 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr @@ -82,12 +92,19 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
private static Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredLanguageCode ?? string.Empty)
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredAudioLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
.ToValidation<BaseError>("Preferred audio language code is invalid");
private static Validation<BaseError, string> ValidatePreferredSubtitleLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredSubtitleLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred subtitle language code is invalid");
private static async Task<Validation<BaseError, string>> ValidateNumber(TvContext dbContext, CreateChannel createChannel)
{

6
ErsatzTV.Application/Channels/Commands/UpdateChannel.cs

@ -12,7 +12,9 @@ public record UpdateChannel @@ -12,7 +12,9 @@ public record UpdateChannel
string Categories,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
string PreferredAudioLanguageCode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, ChannelViewModel>>;
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, ChannelViewModel>>;

12
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -32,7 +32,9 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr @@ -32,7 +32,9 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
c.Group = update.Group;
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
c.PreferredLanguageCode = update.PreferredLanguageCode;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
c.SubtitleMode = update.SubtitleMode;
c.Artwork ??= new List<Artwork>();
if (!string.IsNullOrWhiteSpace(update.Logo))
@ -69,7 +71,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr @@ -69,7 +71,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
(await ChannelMustExist(dbContext, request), ValidateName(request),
await ValidateNumber(dbContext, request),
ValidatePreferredLanguage(request))
ValidatePreferredAudioLanguage(request))
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
@ -106,10 +108,10 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr @@ -106,10 +108,10 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
return BaseError.New("Channel number must be unique");
}
private static Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredLanguageCode ?? string.Empty)
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredAudioLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
.ToValidation<BaseError>("Preferred audio language code is invalid");
}

8
ErsatzTV.Application/Channels/Mapper.cs

@ -14,11 +14,13 @@ internal static class Mapper @@ -14,11 +14,13 @@ internal static class Mapper
channel.Categories,
channel.FFmpegProfileId,
GetLogo(channel),
channel.PreferredLanguageCode,
channel.PreferredAudioLanguageCode,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0);
channel.Playouts?.Count ?? 0,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
@ -26,7 +28,7 @@ internal static class Mapper @@ -26,7 +28,7 @@ internal static class Mapper
channel.Number,
channel.Name,
channel.FFmpegProfile.Name,
channel.PreferredLanguageCode,
channel.PreferredAudioLanguageCode,
GetStreamingMode(channel));
private static string GetLogo(Channel channel) =>

2
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -83,7 +83,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, @@ -83,7 +83,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegPreferredLanguageCode,
request.Settings.PreferredLanguageCode);
request.Settings.PreferredAudioLanguageCode);
if (request.Settings.GlobalWatermarkId is not null)
{

2
ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs

@ -5,7 +5,7 @@ public class FFmpegSettingsViewModel @@ -5,7 +5,7 @@ public class FFmpegSettingsViewModel
public string FFmpegPath { get; set; }
public string FFprobePath { get; set; }
public int DefaultFFmpegProfileId { get; set; }
public string PreferredLanguageCode { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; }
public int? GlobalFallbackFillerId { get; set; }

2
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs

@ -16,7 +16,7 @@ public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById, @@ -16,7 +16,7 @@ public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById,
GetFFmpegProfileById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)

4
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs

@ -20,7 +20,7 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe @@ -20,7 +20,7 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
Option<string> preferredLanguageCode =
Option<string> preferredAudioLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
@ -39,7 +39,7 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe @@ -39,7 +39,7 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),

3
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs

@ -419,6 +419,9 @@ public class TranscodingTests @@ -419,6 +419,9 @@ public class TranscodingTests
public Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version) =>
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
public Task<Option<MediaStream>> SelectSubtitleStream(Channel channel, MediaVersion version) =>
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Subtitle)).AsTask();
}
private static string ExecutableName(string baseName) =>

4
ErsatzTV.Core/Domain/Channel.cs

@ -22,5 +22,7 @@ public class Channel @@ -22,5 +22,7 @@ public class Channel
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
public string PreferredLanguageCode { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode SubtitleMode { get; set; }
}

9
ErsatzTV.Core/Domain/ChannelSubtitleMode.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelSubtitleMode
{
None = 0,
Forced = 1,
Default = 2,
Any = 3
}

30
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -55,6 +55,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -55,6 +55,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
Option<MediaStream> maybeSubtitleStream =
await _ffmpegStreamSelector.SelectSubtitleStream(channel, videoVersion);
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
@ -120,7 +122,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -120,7 +122,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return new AudioInputFile(audioPath, new List<AudioStream> { ffmpegAudioStream }, audioState);
});
var watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
Option<SubtitleInputFile> subtitleInputFile = maybeSubtitleStream.Map(
subtitleStream =>
{
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(
subtitleStream.Index,
subtitleStream.Codec,
StreamKind.Video);
return new SubtitleInputFile(
videoPath,
new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream },
false);
// TODO: figure out HLS direct
// channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect);
});
Option<WatermarkInputFile> watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
string videoFormat = playbackSettings.VideoFormat switch
{
@ -192,6 +211,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -192,6 +211,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoInputFile,
audioInputFile,
watermarkInputFile,
subtitleInputFile,
FileSystemLayout.FFmpegReportsFolder,
_logger);
@ -278,7 +298,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -278,7 +298,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
$"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}",
resolution);
var pipelineBuilder = new PipelineBuilder(None, None, None, FileSystemLayout.FFmpegReportsFolder, _logger);
var pipelineBuilder = new PipelineBuilder(
None,
None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
_logger);
FFmpegPipeline pipeline = pipelineBuilder.Concat(
concatInputFile,

82
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs

@ -27,27 +27,27 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -27,27 +27,27 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
{
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
string.IsNullOrWhiteSpace(channel.PreferredAudioLanguageCode))
{
_logger.LogDebug(
"Channel {Number} is HLS with no preferred language; using all audio streams",
"Channel {Number} is HLS Direct with no preferred audio language; using all audio streams",
channel.Number);
return None;
}
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
string language = (channel.PreferredLanguageCode ?? string.Empty).ToLowerInvariant();
string language = (channel.PreferredAudioLanguageCode ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language))
{
_logger.LogDebug("Channel {Number} has no preferred language code", channel.Number);
_logger.LogDebug("Channel {Number} has no preferred audio language code", channel.Number);
Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegPreferredLanguageCode);
maybeDefaultLanguage.Match(
lang => language = lang.ToLowerInvariant(),
() =>
{
_logger.LogDebug("FFmpeg has no preferred language code; falling back to {Code}", "eng");
_logger.LogDebug("FFmpeg has no preferred audio language code; falling back to {Code}", "eng");
language = "eng";
});
}
@ -55,7 +55,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -55,7 +55,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
if (allCodes.Count > 1)
{
_logger.LogDebug("Preferred language has multiple codes {Codes}", allCodes);
_logger.LogDebug("Preferred audio language has multiple codes {Codes}", allCodes);
}
var correctLanguage = audioStreams.Filter(
@ -67,7 +67,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -67,7 +67,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
if (correctLanguage.Any())
{
_logger.LogDebug(
"Found {Count} audio streams with preferred language code(s) {Code}; selecting stream with most channels",
"Found {Count} audio streams with preferred audio language code(s) {Code}; selecting stream with most channels",
correctLanguage.Count,
allCodes);
@ -75,9 +75,75 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -75,9 +75,75 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
}
_logger.LogDebug(
"Unable to find audio stream with preferred language code(s) {Code}; selecting stream with most channels",
"Unable to find audio stream with preferred audio language code(s) {Code}; selecting stream with most channels",
allCodes);
return audioStreams.OrderByDescending(s => s.Channels).Head();
}
public async Task<Option<MediaStream>> SelectSubtitleStream(Channel channel, MediaVersion version)
{
if (channel.SubtitleMode == ChannelSubtitleMode.None)
{
return None;
}
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(channel.PreferredSubtitleLanguageCode))
{
// _logger.LogDebug(
// "Channel {Number} is HLS Direct with no preferred subtitle language; using all subtitle streams",
// channel.Number);
return None;
}
var subtitleStreams = version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
// .Filter(s => s.Codec is "hdmv_pgs_subtitle" or "dvd_subtitle")
.ToList();
string language = (channel.PreferredSubtitleLanguageCode ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language))
{
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number);
}
else
{
// filter to preferred language
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
subtitleStreams = subtitleStreams
.Filter(
s => allCodes.Any(c => string.Equals(s.Language, c, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
}
if (subtitleStreams.Count == 0)
{
return None;
}
switch (channel.SubtitleMode)
{
case ChannelSubtitleMode.Forced:
foreach (MediaStream stream in Optional(subtitleStreams.OrderBy(s => s.Index).Find(s => s.Forced)))
{
return stream;
}
break;
case ChannelSubtitleMode.Default:
foreach (MediaStream stream in Optional(subtitleStreams.OrderBy(s => s.Index).Find(s => s.Default)))
{
return stream;
}
break;
case ChannelSubtitleMode.Any:
foreach (MediaStream stream in subtitleStreams.OrderBy(s => s.Index).HeadOrNone())
{
return stream;
}
break;
}
return None;
}
}

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

@ -6,4 +6,6 @@ public interface IFFmpegStreamSelector @@ -6,4 +6,6 @@ public interface IFFmpegStreamSelector
{
Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version);
Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version);
Task<Option<MediaStream>> SelectSubtitleStream(Channel channel, MediaVersion version);
}

6
ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs

@ -67,7 +67,7 @@ public class PipelineGeneratorTests @@ -67,7 +67,7 @@ public class PipelineGeneratorTests
Option<string>.None,
0);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, "", _logger);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", _logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0);
@ -82,7 +82,7 @@ public class PipelineGeneratorTests @@ -82,7 +82,7 @@ public class PipelineGeneratorTests
var resolution = new FrameSize(1920, 1080);
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", resolution);
var builder = new PipelineBuilder(None, None, None, "", _logger);
var builder = new PipelineBuilder(None, None, None, None, "", _logger);
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
result.PipelineSteps.Should().HaveCountGreaterThan(0);
@ -142,7 +142,7 @@ public class PipelineGeneratorTests @@ -142,7 +142,7 @@ public class PipelineGeneratorTests
Option<string>.None,
0);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, "", _logger);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", _logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0);

13
ErsatzTV.FFmpeg/Encoder/EncoderBase.cs

@ -8,7 +8,18 @@ public abstract class EncoderBase : IEncoder @@ -8,7 +8,18 @@ public abstract class EncoderBase : IEncoder
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> FilterOptions => Array.Empty<string>();
public virtual IList<string> OutputOptions => new List<string> { Kind == StreamKind.Video ? "-c:v" : "-c:a", Name };
public virtual IList<string> OutputOptions => new List<string>
{
Kind switch
{
StreamKind.Video => "-c:v",
StreamKind.Audio => "-c:a",
StreamKind.Subtitle => "-c:s",
_ => throw new ArgumentOutOfRangeException()
},
Name
};
public virtual FrameState NextState(FrameState currentState) => currentState;
public abstract string Name { get; }

8
ErsatzTV.FFmpeg/Encoder/EncoderCopySubtitle.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Encoder;
public class EncoderCopySubtitle : EncoderBase
{
public override FrameState NextState(FrameState currentState) => currentState;
public override string Name => "copy";
public override StreamKind Kind => StreamKind.Subtitle;
}

15
ErsatzTV.FFmpeg/Filter/AvailableSubtitleOverlayFilters.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using ErsatzTV.FFmpeg.Filter.Cuda;
using ErsatzTV.FFmpeg.Filter.Qsv;
namespace ErsatzTV.FFmpeg.Filter;
public static class AvailableSubtitleOverlayFilters
{
public static IPipelineFilterStep ForAcceleration(HardwareAccelerationMode accelMode) =>
accelMode switch
{
HardwareAccelerationMode.Nvenc => new OverlaySubtitleCudaFilter(),
HardwareAccelerationMode.Qsv => new OverlaySubtitleQsvFilter(),
_ => new OverlaySubtitleFilter()
};
}

8
ErsatzTV.FFmpeg/Filter/AvailableOverlayFilters.cs → ErsatzTV.FFmpeg/Filter/AvailableWatermarkOverlayFilters.cs

@ -4,7 +4,7 @@ using ErsatzTV.FFmpeg.State; @@ -4,7 +4,7 @@ using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.FFmpeg.Filter;
public static class AvailableOverlayFilters
public static class AvailableWatermarkOverlayFilters
{
public static IPipelineFilterStep ForAcceleration(
HardwareAccelerationMode accelMode,
@ -13,8 +13,8 @@ public static class AvailableOverlayFilters @@ -13,8 +13,8 @@ public static class AvailableOverlayFilters
FrameSize resolution) =>
accelMode switch
{
HardwareAccelerationMode.Nvenc => new OverlayCudaFilter(currentState, watermarkState, resolution),
HardwareAccelerationMode.Qsv => new OverlayQsvFilter(currentState, watermarkState, resolution),
_ => new OverlayFilter(currentState, watermarkState, resolution)
HardwareAccelerationMode.Nvenc => new OverlayWatermarkCudaFilter(currentState, watermarkState, resolution),
HardwareAccelerationMode.Qsv => new OverlayWatermarkQsvFilter(currentState, watermarkState, resolution),
_ => new OverlayWatermarkFilter(currentState, watermarkState, resolution)
};
}

87
ErsatzTV.FFmpeg/Filter/ComplexFilter.cs

@ -9,6 +9,7 @@ public class ComplexFilter : IPipelineStep @@ -9,6 +9,7 @@ public class ComplexFilter : IPipelineStep
private readonly Option<VideoInputFile> _maybeVideoInputFile;
private readonly Option<AudioInputFile> _maybeAudioInputFile;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
private readonly FrameSize _resolution;
public ComplexFilter(
@ -17,6 +18,7 @@ public class ComplexFilter : IPipelineStep @@ -17,6 +18,7 @@ public class ComplexFilter : IPipelineStep
Option<VideoInputFile> maybeVideoInputFile,
Option<AudioInputFile> maybeAudioInputFile,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile,
FrameSize resolution)
{
_currentState = currentState;
@ -24,6 +26,7 @@ public class ComplexFilter : IPipelineStep @@ -24,6 +26,7 @@ public class ComplexFilter : IPipelineStep
_maybeVideoInputFile = maybeVideoInputFile;
_maybeAudioInputFile = maybeAudioInputFile;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
_resolution = resolution;
}
@ -32,13 +35,16 @@ public class ComplexFilter : IPipelineStep @@ -32,13 +35,16 @@ public class ComplexFilter : IPipelineStep
var audioLabel = "0:a";
var videoLabel = "0:v";
string watermarkLabel;
string subtitleLabel;
var result = new List<string>();
string audioFilterComplex = string.Empty;
string videoFilterComplex = string.Empty;
string watermarkFilterComplex = string.Empty;
string overlayFilterComplex = string.Empty;
string watermarkOverlayFilterComplex = string.Empty;
string subtitleFilterComplex = string.Empty;
string subtitleOverlayFilterComplex = string.Empty;
var distinctPaths = new List<string>();
foreach ((string path, _) in _maybeVideoInputFile)
@ -65,6 +71,14 @@ public class ComplexFilter : IPipelineStep @@ -65,6 +71,14 @@ public class ComplexFilter : IPipelineStep
}
}
foreach ((string path, _) in _maybeSubtitleInputFile)
{
if (!distinctPaths.Contains(path))
{
distinctPaths.Add(path);
}
}
foreach (VideoInputFile videoInputFile in _maybeVideoInputFile)
{
int inputIndex = distinctPaths.IndexOf(videoInputFile.Path);
@ -117,7 +131,7 @@ public class ComplexFilter : IPipelineStep @@ -117,7 +131,7 @@ public class ComplexFilter : IPipelineStep
watermarkFilterComplex += watermarkLabel;
}
IPipelineFilterStep overlayFilter = AvailableOverlayFilters.ForAcceleration(
IPipelineFilterStep overlayFilter = AvailableWatermarkOverlayFilters.ForAcceleration(
_ffmpegState.HardwareAccelerationMode,
_currentState,
watermarkInputFile.DesiredState,
@ -141,19 +155,74 @@ public class ComplexFilter : IPipelineStep @@ -141,19 +155,74 @@ public class ComplexFilter : IPipelineStep
uploadFilter = "," + uploadFilter;
}
overlayFilterComplex = $"{tempVideoLabel}{watermarkLabel}{overlayFilter.Filter}{uploadFilter}[vf]";
watermarkOverlayFilterComplex = $"{tempVideoLabel}{watermarkLabel}{overlayFilter.Filter}{uploadFilter}[vf]";
// change the mapped label
videoLabel = "[vf]";
}
}
}
foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(s => !s.Copy))
{
int inputIndex = distinctPaths.IndexOf(subtitleInputFile.Path);
foreach ((int index, _, _) in subtitleInputFile.Streams)
{
subtitleLabel = $"{inputIndex}:{index}";
if (subtitleInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
subtitleFilterComplex += $"[{inputIndex}:{index}]";
subtitleFilterComplex += string.Join(
",",
subtitleInputFile.FilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
subtitleLabel = "[st]";
subtitleFilterComplex += subtitleLabel;
}
IPipelineFilterStep overlayFilter =
AvailableSubtitleOverlayFilters.ForAcceleration(_ffmpegState.HardwareAccelerationMode);
if (overlayFilter.Filter != string.Empty)
{
string tempVideoLabel = string.IsNullOrWhiteSpace(videoFilterComplex) &&
string.IsNullOrWhiteSpace(watermarkFilterComplex)
? $"[{videoLabel}]"
: videoLabel;
// vaapi uses software overlay and needs to upload
string uploadFilter = string.Empty;
if (_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
{
uploadFilter = new HardwareUploadFilter(_ffmpegState).Filter;
}
if (!string.IsNullOrWhiteSpace(uploadFilter))
{
uploadFilter = "," + uploadFilter;
}
subtitleOverlayFilterComplex =
$"{tempVideoLabel}{subtitleLabel}{overlayFilter.Filter}{uploadFilter}[vst]";
// change the mapped label
videoLabel = "[vst]";
}
}
}
if (!string.IsNullOrWhiteSpace(audioFilterComplex) || !string.IsNullOrWhiteSpace(videoFilterComplex))
{
var filterComplex = string.Join(
";",
new[] { audioFilterComplex, videoFilterComplex, watermarkFilterComplex, overlayFilterComplex }.Where(
new[]
{
audioFilterComplex,
videoFilterComplex,
watermarkFilterComplex,
subtitleFilterComplex,
watermarkOverlayFilterComplex,
subtitleOverlayFilterComplex
}.Where(
s => !string.IsNullOrWhiteSpace(s)));
result.AddRange(new[] { "-filter_complex", filterComplex });
@ -161,6 +230,16 @@ public class ComplexFilter : IPipelineStep @@ -161,6 +230,16 @@ public class ComplexFilter : IPipelineStep
result.AddRange(new[] { "-map", audioLabel, "-map", videoLabel });
foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(s => s.Copy))
{
int inputIndex = distinctPaths.IndexOf(subtitleInputFile.Path);
foreach ((int index, _, _) in subtitleInputFile.Streams)
{
subtitleLabel = $"{inputIndex}:{index}";
result.AddRange(new[] { "-map", subtitleLabel });
}
}
return result;
}

8
ErsatzTV.FFmpeg/Filter/Cuda/OverlaySubtitleCudaFilter.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Filter.Cuda;
public class OverlaySubtitleCudaFilter : BaseFilter
{
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter => "overlay_cuda";
}

4
ErsatzTV.FFmpeg/Filter/Cuda/OverlayCudaFilter.cs → ErsatzTV.FFmpeg/Filter/Cuda/OverlayWatermarkCudaFilter.cs

@ -2,9 +2,9 @@ @@ -2,9 +2,9 @@
namespace ErsatzTV.FFmpeg.Filter.Cuda;
public class OverlayCudaFilter : OverlayFilter
public class OverlayWatermarkCudaFilter : OverlayWatermarkFilter
{
public OverlayCudaFilter(FrameState currentState, WatermarkState watermarkState, FrameSize resolution) : base(
public OverlayWatermarkCudaFilter(FrameState currentState, WatermarkState watermarkState, FrameSize resolution) : base(
currentState,
watermarkState,
resolution)

8
ErsatzTV.FFmpeg/Filter/OverlaySubtitleFilter.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Filter;
public class OverlaySubtitleFilter : BaseFilter
{
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter => "overlay";
}

4
ErsatzTV.FFmpeg/Filter/OverlayFilter.cs → ErsatzTV.FFmpeg/Filter/OverlayWatermarkFilter.cs

@ -2,13 +2,13 @@ @@ -2,13 +2,13 @@
namespace ErsatzTV.FFmpeg.Filter;
public class OverlayFilter : BaseFilter
public class OverlayWatermarkFilter : BaseFilter
{
private readonly FrameState _currentState;
private readonly WatermarkState _watermarkState;
private readonly FrameSize _resolution;
public OverlayFilter(FrameState currentState, WatermarkState watermarkState, FrameSize resolution)
public OverlayWatermarkFilter(FrameState currentState, WatermarkState watermarkState, FrameSize resolution)
{
_currentState = currentState;
_watermarkState = watermarkState;

8
ErsatzTV.FFmpeg/Filter/Qsv/OverlaySubtitleQsvFilter.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Filter.Qsv;
public class OverlaySubtitleQsvFilter : BaseFilter
{
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter => "overlay_qsv";
}

4
ErsatzTV.FFmpeg/Filter/Qsv/OverlayQsvFilter.cs → ErsatzTV.FFmpeg/Filter/Qsv/OverlayWatermarkQsvFilter.cs

@ -2,9 +2,9 @@ @@ -2,9 +2,9 @@
namespace ErsatzTV.FFmpeg.Filter.Qsv;
public class OverlayQsvFilter : OverlayFilter
public class OverlayWatermarkQsvFilter : OverlayWatermarkFilter
{
public OverlayQsvFilter(FrameState currentState, WatermarkState watermarkState, FrameSize resolution) : base(
public OverlayWatermarkQsvFilter(FrameState currentState, WatermarkState watermarkState, FrameSize resolution) : base(
currentState,
watermarkState,
resolution)

32
ErsatzTV.FFmpeg/Filter/SubtitleHardwareUploadFilter.cs

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
namespace ErsatzTV.FFmpeg.Filter;
public class SubtitleHardwareUploadFilter : BaseFilter
{
private readonly FrameState _currentState;
private readonly FFmpegState _ffmpegState;
public SubtitleHardwareUploadFilter(FrameState currentState, FFmpegState ffmpegState)
{
_currentState = currentState;
_ffmpegState = ffmpegState;
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter =>
_ffmpegState.HardwareAccelerationMode switch
{
HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
// leave vaapi in software since we don't (yet) use overlay_vaapi
HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software =>
string.Empty,
// leave videotoolbox in software since we use a software overlay filter
HardwareAccelerationMode.VideoToolbox => string.Empty,
_ => "hwupload"
};
}

28
ErsatzTV.FFmpeg/Filter/SubtitlePixelFormatFilter.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
namespace ErsatzTV.FFmpeg.Filter;
public class SubtitlePixelFormatFilter : BaseFilter
{
private readonly FFmpegState _ffmpegState;
public SubtitlePixelFormatFilter(FFmpegState ffmpegState)
{
_ffmpegState = ffmpegState;
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter
{
get
{
Option<string> maybeFormat = _ffmpegState.HardwareAccelerationMode switch
{
HardwareAccelerationMode.Nvenc => "yuva420p",
HardwareAccelerationMode.Qsv => "yuva420p",
_ => None
};
return maybeFormat.Match(f => $"format={f}", () => string.Empty);
}
}
}

5
ErsatzTV.FFmpeg/InputFile.cs

@ -60,3 +60,8 @@ public record VideoInputFile(string Path, IList<VideoStream> VideoStreams) : Inp @@ -60,3 +60,8 @@ public record VideoInputFile(string Path, IList<VideoStream> VideoStreams) : Inp
public record WatermarkInputFile
(string Path, IList<VideoStream> VideoStreams, WatermarkState DesiredState) : VideoInputFile(Path, VideoStreams);
public record SubtitleInputFile(string Path, IList<MediaStream> SubtitleStreams, bool Copy) : InputFile(Path, SubtitleStreams)
{
public bool IsImageBased = SubtitleStreams.All(s => s.Codec is "hdmv_pgs_subtitle" or "dvd_subtitle");
}

47
ErsatzTV.FFmpeg/PipelineBuilder.cs

@ -20,6 +20,7 @@ public class PipelineBuilder @@ -20,6 +20,7 @@ public class PipelineBuilder
private readonly Option<VideoInputFile> _videoInputFile;
private readonly Option<AudioInputFile> _audioInputFile;
private readonly Option<WatermarkInputFile> _watermarkInputFile;
private readonly Option<SubtitleInputFile> _subtitleInputFile;
private readonly string _reportsFolder;
private readonly ILogger _logger;
@ -27,6 +28,7 @@ public class PipelineBuilder @@ -27,6 +28,7 @@ public class PipelineBuilder
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
string reportsFolder,
ILogger logger)
{
@ -46,6 +48,7 @@ public class PipelineBuilder @@ -46,6 +48,7 @@ public class PipelineBuilder
_videoInputFile = videoInputFile;
_audioInputFile = audioInputFile;
_watermarkInputFile = watermarkInputFile;
_subtitleInputFile = subtitleInputFile;
_reportsFolder = reportsFolder;
_logger = logger;
}
@ -114,6 +117,9 @@ public class PipelineBuilder @@ -114,6 +117,9 @@ public class PipelineBuilder
foreach (VideoStream videoStream in allVideoStreams)
{
bool hasOverlay = _watermarkInputFile.IsSome ||
_subtitleInputFile.Map(s => s.IsImageBased && !s.Copy).IfNone(false);
Option<int> initialFrameRate = Option<int>.None;
foreach (string frameRateString in videoStream.FrameRate)
{
@ -166,15 +172,13 @@ public class PipelineBuilder @@ -166,15 +172,13 @@ public class PipelineBuilder
}
// nvenc requires yuv420p background with yuva420p overlay
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Nvenc &&
_watermarkInputFile.IsSome)
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Nvenc && hasOverlay)
{
desiredState = desiredState with { PixelFormat = new PixelFormatYuv420P() };
}
// qsv should stay nv12
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Qsv &&
_watermarkInputFile.IsSome)
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Qsv && hasOverlay)
{
IPixelFormat pixelFormat = desiredState.PixelFormat.IfNone(new PixelFormatYuv420P());
desiredState = desiredState with { PixelFormat = new PixelFormatNv12(pixelFormat.Name) };
@ -202,6 +206,11 @@ public class PipelineBuilder @@ -202,6 +206,11 @@ public class PipelineBuilder
}
}
if (_subtitleInputFile.Map(s => s.Copy) == Some(true))
{
_pipelineSteps.Add(new EncoderCopySubtitle());
}
if (videoStream.StillImage)
{
var option = new InfiniteLoopInputOption(ffmpegState.HardwareAccelerationMode);
@ -342,7 +351,7 @@ public class PipelineBuilder @@ -342,7 +351,7 @@ public class PipelineBuilder
_videoInputFile.Iter(f => f.FilterSteps.Add(sarStep));
}
if (_watermarkInputFile.IsSome && currentState.PixelFormat.Map(pf => pf.FFmpegName) !=
if (hasOverlay && currentState.PixelFormat.Map(pf => pf.FFmpegName) !=
desiredState.PixelFormat.Map(pf => pf.FFmpegName))
{
// this should only happen with nvenc?
@ -383,7 +392,7 @@ public class PipelineBuilder @@ -383,7 +392,7 @@ public class PipelineBuilder
}
}
}
// nvenc custom logic
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Nvenc)
{
@ -393,11 +402,10 @@ public class PipelineBuilder @@ -393,11 +402,10 @@ public class PipelineBuilder
bool onlyYadif = videoInputFile.FilterSteps.Count == 1 &&
videoInputFile.FilterSteps.Any(fs => fs is YadifCudaFilter);
// if we have no filters and a watermark, we need to set pixel format
bool unfilteredWithWatermark = videoInputFile.FilterSteps.Count == 0
&& _watermarkInputFile.IsSome;
// if we have no filters and an overlay, we need to set pixel format
bool unfilteredWithOverlay = videoInputFile.FilterSteps.Count == 0 && hasOverlay;
if (onlyYadif || unfilteredWithWatermark)
if (onlyYadif || unfilteredWithOverlay)
{
// the filter re-applies the current pixel format, so we have to set it first
currentState = currentState with { PixelFormat = desiredState.PixelFormat };
@ -440,7 +448,7 @@ public class PipelineBuilder @@ -440,7 +448,7 @@ public class PipelineBuilder
}
// after everything else is done, apply the encoder
if (!_pipelineSteps.OfType<IEncoder>().Any())
if (_pipelineSteps.OfType<IEncoder>().All(e => e.Kind != StreamKind.Video))
{
foreach (IEncoder e in AvailableEncoders.ForVideoFormat(
ffmpegState,
@ -500,6 +508,22 @@ public class PipelineBuilder @@ -500,6 +508,22 @@ public class PipelineBuilder
}
}
foreach (SubtitleInputFile subtitleInputFile in _subtitleInputFile)
{
// vaapi and videotoolbox use a software overlay, so we need to ensure the background is already in software
// though videotoolbox uses software decoders, so no need to download for that
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
{
var downloadFilter = new HardwareDownloadFilter(currentState);
currentState = downloadFilter.NextState(currentState);
_videoInputFile.Iter(f => f.FilterSteps.Add(downloadFilter));
}
subtitleInputFile.FilterSteps.Add(new SubtitlePixelFormatFilter(ffmpegState));
subtitleInputFile.FilterSteps.Add(new SubtitleHardwareUploadFilter(currentState, ffmpegState));
}
foreach (WatermarkInputFile watermarkInputFile in _watermarkInputFile)
{
// vaapi and videotoolbox use a software overlay, so we need to ensure the background is already in software
@ -597,6 +621,7 @@ public class PipelineBuilder @@ -597,6 +621,7 @@ public class PipelineBuilder
_videoInputFile,
_audioInputFile,
_watermarkInputFile,
_subtitleInputFile,
currentState.PaddedSize);
_pipelineSteps.Add(complexFilter);

1
ErsatzTV.FFmpeg/StreamKind.cs

@ -4,5 +4,6 @@ public enum StreamKind @@ -4,5 +4,6 @@ public enum StreamKind
{
Audio,
Video,
Subtitle,
All
}

4
ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs

@ -22,8 +22,8 @@ public class MediaItemRepository : IMediaItemRepository @@ -22,8 +22,8 @@ public class MediaItemRepository : IMediaItemRepository
@"SELECT LanguageCode FROM
(SELECT Language AS LanguageCode
FROM MediaStream WHERE Language IS NOT NULL
UNION ALL SELECT PreferredLanguageCode AS LanguageCode
FROM Channel WHERE PreferredLanguageCode IS NOT NULL)
UNION ALL SELECT PreferredAudioLanguageCode AS LanguageCode
FROM Channel WHERE PreferredAudioLanguageCode IS NOT NULL)
GROUP BY LanguageCode
ORDER BY COUNT(LanguageCode) DESC")
.Map(result => result.ToList());

3885
ErsatzTV.Infrastructure/Migrations/20220329005536_Add_FFmpegProfileSubtitleMode.Designer.cs generated

File diff suppressed because it is too large Load Diff

47
ErsatzTV.Infrastructure/Migrations/20220329005536_Add_FFmpegProfileSubtitleMode.cs

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_FFmpegProfileSubtitleMode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<bool>(
name: "DeinterlaceVideo",
table: "FFmpegProfile",
type: "INTEGER",
nullable: true,
defaultValue: true,
oldClrType: typeof(bool),
oldType: "INTEGER",
oldDefaultValue: true);
migrationBuilder.AddColumn<int>(
name: "SubtitleMode",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SubtitleMode",
table: "FFmpegProfile");
migrationBuilder.AlterColumn<bool>(
name: "DeinterlaceVideo",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: true,
oldClrType: typeof(bool),
oldType: "INTEGER",
oldNullable: true,
oldDefaultValue: true);
}
}
}

3888
ErsatzTV.Infrastructure/Migrations/20220330162314_Remove_FFmpegProfileSubtitleMode.Designer.cs generated

File diff suppressed because it is too large Load Diff

57
ErsatzTV.Infrastructure/Migrations/20220330162314_Remove_FFmpegProfileSubtitleMode.cs

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_FFmpegProfileSubtitleMode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SubtitleMode",
table: "FFmpegProfile");
migrationBuilder.RenameColumn(
name: "PreferredLanguageCode",
table: "Channel",
newName: "PreferredAudioLanguageCode");
migrationBuilder.AddColumn<string>(
name: "PreferredSubtitleLanguageCode",
table: "Channel",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SubtitleMode",
table: "Channel",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PreferredSubtitleLanguageCode",
table: "Channel");
migrationBuilder.DropColumn(
name: "SubtitleMode",
table: "Channel");
migrationBuilder.RenameColumn(
name: "PreferredAudioLanguageCode",
table: "Channel",
newName: "PreferredLanguageCode");
migrationBuilder.AddColumn<int>(
name: "SubtitleMode",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
}
}

10
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -239,12 +239,18 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -239,12 +239,18 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<string>("PreferredLanguageCode")
b.Property<string>("PreferredAudioLanguageCode")
.HasColumnType("TEXT");
b.Property<string>("PreferredSubtitleLanguageCode")
.HasColumnType("TEXT");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
b.Property<int>("SubtitleMode")
.HasColumnType("INTEGER");
b.Property<Guid>("UniqueId")
.HasColumnType("TEXT");
@ -505,7 +511,7 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -505,7 +511,7 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("AudioSampleRate")
.HasColumnType("INTEGER");
b.Property<bool>("DeinterlaceVideo")
b.Property<bool?>("DeinterlaceVideo")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);

6
ErsatzTV/Controllers/IptvController.cs

@ -83,9 +83,9 @@ public class IptvController : ControllerBase @@ -83,9 +83,9 @@ public class IptvController : ControllerBase
Process process = processModel.Process;
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
// _logger.LogDebug(
// "ffmpeg concat arguments {FFmpegArguments}",
// string.Join(" ", process.StartInfo.ArgumentList));
_logger.LogDebug(
"ffmpeg ts arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},

27
ErsatzTV/Pages/ChannelEditor.razor

@ -44,9 +44,9 @@ @@ -44,9 +44,9 @@
}
</MudSelect>
<MudSelect Class="mt-3"
Label="Preferred Language"
@bind-Value="_model.PreferredLanguageCode"
For="@(() => _model.PreferredLanguageCode)"
Label="Preferred Audio Language"
@bind-Value="_model.PreferredAudioLanguageCode"
For="@(() => _model.PreferredAudioLanguageCode)"
Clearable="true">
<MudSelectItem Value="@((string) null)">(none)</MudSelectItem>
@foreach (CultureInfo culture in _availableCultures)
@ -54,6 +54,23 @@ @@ -54,6 +54,23 @@
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3"
Label="Preferred Subtitle Language"
@bind-Value="_model.PreferredSubtitleLanguageCode"
For="@(() => _model.PreferredSubtitleLanguageCode)"
Clearable="true">
<MudSelectItem Value="@((string) null)">(none)</MudSelectItem>
@foreach (CultureInfo culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3" Label="Subtitle Mode" @bind-Value="_model.SubtitleMode" For="@(() => _model.SubtitleMode)">
<MudSelectItem Value="@(ChannelSubtitleMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Forced)">Forced</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem>
</MudSelect>
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6">
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
@ -145,9 +162,11 @@ @@ -145,9 +162,11 @@
_model.FFmpegProfileId = channelViewModel.FFmpegProfileId;
_model.Logo = channelViewModel.Logo;
_model.StreamingMode = channelViewModel.StreamingMode;
_model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode;
_model.PreferredAudioLanguageCode = channelViewModel.PreferredAudioLanguageCode;
_model.WatermarkId = channelViewModel.WatermarkId;
_model.FallbackFillerId = channelViewModel.FallbackFillerId;
_model.PreferredSubtitleLanguageCode = channelViewModel.PreferredSubtitleLanguageCode;
_model.SubtitleMode = channelViewModel.SubtitleMode;
},
() => _navigationManager.NavigateTo("404"));
}

6
ErsatzTV/Pages/Channels.razor

@ -46,7 +46,7 @@ @@ -46,7 +46,7 @@
}
</MudTd>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Language">@context.PreferredLanguageCode</MudTd>
<MudTd DataLabel="Language">@context.PreferredAudioLanguageCode</MudTd>
<MudTd DataLabel="Mode">@GetStreamingMode(context.StreamingMode)</MudTd>
<MudTd DataLabel="FFmpeg Profile">
@if (context.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
@ -127,11 +127,11 @@ @@ -127,11 +127,11 @@
Option<CultureInfo> maybeCultureInfo = allCultures.Find(
ci => string.Equals(
ci.ThreeLetterISOLanguageName,
channel.PreferredLanguageCode,
channel.PreferredAudioLanguageCode,
StringComparison.OrdinalIgnoreCase));
maybeCultureInfo.Match(
cultureInfo => processedChannels.Add(channel with { PreferredLanguageCode = cultureInfo.EnglishName }),
cultureInfo => processedChannels.Add(channel with { PreferredAudioLanguageCode = cultureInfo.EnglishName }),
() => processedChannels.Add(channel));
}

2
ErsatzTV/Pages/Settings.razor

@ -34,7 +34,7 @@ @@ -34,7 +34,7 @@
}
</MudSelect>
</MudElement>
<MudSelect Class="mt-3" Label="Preferred Language" @bind-Value="_ffmpegSettings.PreferredLanguageCode" For="@(() => _ffmpegSettings.PreferredLanguageCode)" Required="true" RequiredError="Preferred Language Code is required!">
<MudSelect Class="mt-3" Label="Preferred Audio Language" @bind-Value="_ffmpegSettings.PreferredAudioLanguageCode" For="@(() => _ffmpegSettings.PreferredAudioLanguageCode)" Required="true" RequiredError="Preferred Language Code is required!">
@foreach (CultureInfo culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>

6
ErsatzTV/Validators/ChannelEditViewModelValidator.cs

@ -16,7 +16,7 @@ public class ChannelEditViewModelValidator : AbstractValidator<ChannelEditViewMo @@ -16,7 +16,7 @@ public class ChannelEditViewModelValidator : AbstractValidator<ChannelEditViewMo
RuleFor(x => x.Group).NotEmpty();
RuleFor(x => x.FFmpegProfileId).GreaterThan(0);
RuleFor(x => x.PreferredLanguageCode)
RuleFor(x => x.PreferredAudioLanguageCode)
.Must(
languageCode => CultureInfo.GetCultures(CultureTypes.NeutralCultures)
.Any(
@ -24,7 +24,7 @@ public class ChannelEditViewModelValidator : AbstractValidator<ChannelEditViewMo @@ -24,7 +24,7 @@ public class ChannelEditViewModelValidator : AbstractValidator<ChannelEditViewMo
ci.ThreeLetterISOLanguageName,
languageCode,
StringComparison.OrdinalIgnoreCase)))
.When(vm => !string.IsNullOrWhiteSpace(vm.PreferredLanguageCode))
.WithMessage("Preferred language code is invalid");
.When(vm => !string.IsNullOrWhiteSpace(vm.PreferredAudioLanguageCode))
.WithMessage("Preferred audio language code is invalid");
}
}

16
ErsatzTV/ViewModels/ChannelEditViewModel.cs

@ -11,11 +11,13 @@ public class ChannelEditViewModel @@ -11,11 +11,13 @@ public class ChannelEditViewModel
public string Categories { get; set; }
public string Number { get; set; }
public int FFmpegProfileId { get; set; }
public string PreferredLanguageCode { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string Logo { get; set; }
public StreamingMode StreamingMode { get; set; }
public int? WatermarkId { get; set; }
public int? FallbackFillerId { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode SubtitleMode { get; set; }
public UpdateChannel ToUpdate() =>
new(
@ -26,10 +28,12 @@ public class ChannelEditViewModel @@ -26,10 +28,12 @@ public class ChannelEditViewModel
Categories,
FFmpegProfileId,
Logo,
PreferredLanguageCode,
PreferredAudioLanguageCode,
StreamingMode,
WatermarkId,
FallbackFillerId);
FallbackFillerId,
PreferredSubtitleLanguageCode,
SubtitleMode);
public CreateChannel ToCreate() =>
new(
@ -39,8 +43,10 @@ public class ChannelEditViewModel @@ -39,8 +43,10 @@ public class ChannelEditViewModel
Categories,
FFmpegProfileId,
Logo,
PreferredLanguageCode,
PreferredAudioLanguageCode,
StreamingMode,
WatermarkId,
FallbackFillerId);
FallbackFillerId,
PreferredSubtitleLanguageCode,
SubtitleMode);
}
Loading…
Cancel
Save