Browse Source

song cleanup (#511)

* refactor song background logic

* move song video generation

* move subtitle generation

* build ASS subtitles

* randomize song detail layout

* update changelog
pull/512/head
Jason Dove 5 years ago committed by GitHub
parent
commit
7edf6f5d13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      CHANGELOG.md
  2. 132
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  3. 68
      ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs
  4. 10
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  5. 47
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  6. 238
      ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs
  7. 138
      ErsatzTV.Core/FFmpeg/SubtitleBuilder.cs
  8. 8
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  9. 16
      ErsatzTV.Core/Interfaces/FFmpeg/ISongVideoGenerator.cs
  10. 1
      ErsatzTV/Startup.cs

2
CHANGELOG.md

@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- Randomly place song cover art on left or right side of screen
- Randomly use a solid color from the cover art instead of blurred cover art for song background
- Use dynamic font size (based on resolution) for song details
- Randomly select song detail layout (large title/small artist or small artist/title/album)
## [0.3.0-alpha] - 2021-11-25
### Fixed

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

@ -1,20 +1,16 @@ @@ -1,20 +1,16 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
@ -41,8 +37,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -41,8 +37,7 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo;
private readonly IImageCache _imageCache;
private readonly ITempFilePool _tempFilePool;
private readonly ISongVideoGenerator _songVideoGenerator;
public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
@ -55,8 +50,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -55,8 +50,7 @@ namespace ErsatzTV.Application.Streaming.Queries
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo,
IImageCache imageCache,
ITempFilePool tempFilePool)
ISongVideoGenerator songVideoGenerator)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
@ -68,8 +62,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -68,8 +62,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_runtimeInfo = runtimeInfo;
_imageCache = imageCache;
_tempFilePool = tempFilePool;
_songVideoGenerator = songVideoGenerator;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@ -142,124 +135,11 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -142,124 +135,11 @@ namespace ErsatzTV.Application.Streaming.Queries
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
{
Option<string> subtitleFile = None;
videoVersion = new FallbackMediaVersion
{
Id = -1,
Chapters = new List<MediaChapter>(),
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string[] backgrounds =
{
"background_blank.png",
"background_e.png",
"background_t.png",
"background_v.png"
};
var random = new Random();
// use random ETV color by default
string artworkPath = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
backgrounds[random.Next() % backgrounds.Length]);
// use thumbnail (cover art) if present
foreach (SongMetadata metadata in song.SongMetadata)
{
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
subtitleFile = fileName;
var sb = new StringBuilder();
sb.AppendLine("1");
sb.AppendLine("00:00:00,000 --> 99:99:99,999");
if (!string.IsNullOrWhiteSpace(metadata.Artist))
{
sb.AppendLine(metadata.Artist);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.AppendLine($"\"{metadata.Title}\"");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.AppendLine(metadata.Album);
}
await File.WriteAllTextAsync(fileName, sb.ToString());
foreach (Artwork artwork in Optional(
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
{
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
artworkPath = customPath;
// signal that we want to use cover art as watermark
videoVersion = new CoverArtMediaVersion
{
Chapters = new List<MediaChapter>(),
// always stretch cover art
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
}
}
videoPath = artworkPath;
videoVersion.MediaFiles = new List<MediaFile>
{
new() { Path = videoPath }
};
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
subtitleFile,
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
maybeGlobalWatermark,
videoVersion,
videoPath);
foreach (string si in maybeSongImage.RightToSeq())
{
videoPath = si;
videoVersion = new BackgroundImageMediaVersion
{
Chapters = new List<MediaChapter>(),
// song image has been pre-generated with correct size
Height = channel.FFmpegProfile.Resolution.Height,
Width = channel.FFmpegProfile.Resolution.Width,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 },
},
MediaFiles = new List<MediaFile>
{
new() { Path = si }
}
};
}
ffmpegPath);
}
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements

68
ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
@ -14,8 +13,6 @@ namespace ErsatzTV.Core.FFmpeg @@ -14,8 +13,6 @@ namespace ErsatzTV.Core.FFmpeg
{
public class FFmpegComplexFilterBuilder
{
private static readonly Random Random = new();
private Option<TimeSpan> _audioDuration = None;
private bool _deinterlace;
private Option<HardwareAccelerationKind> _hardwareAccelerationKind = None;
@ -29,6 +26,8 @@ namespace ErsatzTV.Core.FFmpeg @@ -29,6 +26,8 @@ namespace ErsatzTV.Core.FFmpeg
private string _pixelFormat;
private string _videoEncoder;
private Option<string> _subtitle;
private bool _boxBlur;
private Option<int> _randomColor;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@ -96,7 +95,19 @@ namespace ErsatzTV.Core.FFmpeg @@ -96,7 +95,19 @@ namespace ErsatzTV.Core.FFmpeg
_watermarkIndex = watermarkIndex;
return this;
}
public FFmpegComplexFilterBuilder WithBoxBlur(bool boxBlur)
{
_boxBlur = boxBlur;
return this;
}
public FFmpegComplexFilterBuilder WithRandomColor(Option<int> randomColor)
{
_randomColor = randomColor;
return this;
}
public FFmpegComplexFilterBuilder WithSubtitleFile(
MediaVersion videoVersion,
Option<string> subtitleFile)
@ -120,31 +131,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -120,31 +131,7 @@ namespace ErsatzTV.Core.FFmpeg
.Replace(@":/", @"\\:/");
}
var leftMarginPercent = 3;
var rightMarginPercent = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
foreach (ChannelWatermark watermark in _watermark)
{
leftMarginPercent = rightMarginPercent = watermark.HorizontalMarginPercent;
switch (watermark.Location)
{
case ChannelWatermarkLocation.BottomLeft:
leftMarginPercent += watermark.WidthPercent + watermark.HorizontalMarginPercent;
break;
case ChannelWatermarkLocation.BottomRight:
rightMarginPercent += watermark.WidthPercent + watermark.HorizontalMarginPercent;
break;
}
}
double leftMargin = Math.Round(leftMarginPercent / 100.0 * _resolution.Width);
double rightMargin = Math.Round(rightMarginPercent / 100.0 * _resolution.Width);
double verticalMargin = Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * _resolution.Height);
double fontSize = Math.Round(_resolution.Height / 20.0);
_subtitle =
$"subtitles={effectiveFile}:fontsdir={fontsDir}:force_style='PlayResX={_resolution.Width},PlayResY={_resolution.Height},Fontname=OPTIKabel-Heavy,Fontsize={fontSize},PrimaryColour=&HFFFFFF,OutlineColour=&H555555,Alignment=0,MarginR={rightMargin},MarginL={leftMargin},MarginV={verticalMargin},Shadow=3'";
_subtitle = $"subtitles={effectiveFile}:fontsdir={fontsDir}";
}
}
@ -269,7 +256,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -269,7 +256,7 @@ namespace ErsatzTV.Core.FFmpeg
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
};
if (!string.IsNullOrWhiteSpace(filter))
if (_randomColor.IsNone && !string.IsNullOrWhiteSpace(filter))
{
videoFilterQueue.Add(filter);
}
@ -295,29 +282,20 @@ namespace ErsatzTV.Core.FFmpeg @@ -295,29 +282,20 @@ namespace ErsatzTV.Core.FFmpeg
videoFilterQueue.Add(format);
}
if (scaleOrPad)
if (scaleOrPad && _boxBlur == false && _randomColor.IsNone)
{
videoFilterQueue.Add("setsar=1");
}
if (videoOnly)
if (_boxBlur)
{
var filter = "boxblur=40";
int next = Random.Next() % 16;
if (next < 8)
{
videoFilterQueue.RemoveAll(s => s.Contains("scale="));
filter =
$"palettegen=max_colors=8,crop=1:1:{next}:0,scale={_resolution.Width}:{_resolution.Height},setsar=1:1";
}
videoFilterQueue.Add(filter);
videoFilterQueue.Add("boxblur=40");
}
if (isSong)
foreach (int color in _randomColor)
{
videoFilterQueue.Add("fps=30");
videoFilterQueue.Add(
$"palettegen=max_colors=8,crop=1:1:{color}:0,scale={_resolution.Width}:{_resolution.Height},setsar=1");
}
foreach (ChannelWatermark watermark in _watermark)

10
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -282,14 +282,18 @@ namespace ErsatzTV.Core.FFmpeg @@ -282,14 +282,18 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithSongInput(
string videoPath,
Option<string> codec,
Option<string> pixelFormat)
Option<string> pixelFormat,
bool boxBlur,
Option<int> randomColor)
{
_noAutoScale = true;
_outputFramerate = 30;
_complexFilterBuilder = _complexFilterBuilder
.WithInputCodec(codec)
.WithInputPixelFormat(pixelFormat);
.WithInputPixelFormat(pixelFormat)
.WithBoxBlur(boxBlur)
.WithRandomColor(randomColor);
_arguments.Add("-i");
_arguments.Add(videoPath);

47
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -19,7 +19,6 @@ namespace ErsatzTV.Core.FFmpeg @@ -19,7 +19,6 @@ namespace ErsatzTV.Core.FFmpeg
private readonly ITempFilePool _tempFilePool;
private readonly ILogger<FFmpegProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
private readonly Random _random = new();
public FFmpegProcessService(
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
@ -69,7 +68,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -69,7 +68,7 @@ namespace ErsatzTV.Core.FFmpeg
outPoint);
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
@ -242,7 +241,13 @@ namespace ErsatzTV.Core.FFmpeg @@ -242,7 +241,13 @@ namespace ErsatzTV.Core.FFmpeg
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath)
string videoPath,
bool boxBlur,
Option<int> randomColor,
ChannelWatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent)
{
try
{
@ -250,8 +255,22 @@ namespace ErsatzTV.Core.FFmpeg @@ -250,8 +255,22 @@ namespace ErsatzTV.Core.FFmpeg
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<ChannelWatermark> watermarkOverride =
videoVersion is FallbackMediaVersion or CoverArtMediaVersion
? new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
HorizontalMarginPercent = horizontalMarginPercent,
VerticalMarginPercent = verticalMarginPercent,
Location = watermarkLocation,
Size = ChannelWatermarkSize.Scaled,
WidthPercent = watermarkWidthPercent,
Opacity = 100
}
: None;
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
await GetWatermarkOptions(channel, globalWatermark, videoVersion, watermarkOverride);
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
@ -271,7 +290,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -271,7 +290,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur, randomColor)
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
.WithSubtitleFile(videoVersion, subtitleFile);
@ -317,28 +336,14 @@ namespace ErsatzTV.Core.FFmpeg @@ -317,28 +336,14 @@ namespace ErsatzTV.Core.FFmpeg
private async Task<WatermarkOptions> GetWatermarkOptions(
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion)
MediaVersion videoVersion,
Option<ChannelWatermark> watermarkOverride)
{
if (videoVersion is BackgroundImageMediaVersion)
{
return new WatermarkOptions(None, None, None, false);
}
Option<ChannelWatermark> watermarkOverride = videoVersion is FallbackMediaVersion or CoverArtMediaVersion
? new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
HorizontalMarginPercent = 3,
VerticalMarginPercent = 5,
Location = _random.Next() % 2 == 0
? ChannelWatermarkLocation.BottomRight
: ChannelWatermarkLocation.BottomLeft,
Size = ChannelWatermarkSize.Scaled,
WidthPercent = 25,
Opacity = 100
}
: None;
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo)
{

238
ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs

@ -0,0 +1,238 @@ @@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
public class SongVideoGenerator : ISongVideoGenerator
{
private static readonly Random Random = new();
private static readonly object RandomLock = new();
private readonly ITempFilePool _tempFilePool;
private readonly IImageCache _imageCache;
private readonly IFFmpegProcessService _ffmpegProcessService;
public SongVideoGenerator(
ITempFilePool tempFilePool,
IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService)
{
_tempFilePool = tempFilePool;
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
}
public async Task<Tuple<string, MediaVersion>> GenerateSongVideo(
Song song,
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath)
{
Option<string> subtitleFile = None;
MediaVersion videoVersion = new FallbackMediaVersion
{
Id = -1,
Chapters = new List<MediaChapter>(),
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string[] backgrounds =
{
"background_blank.png",
"background_e.png",
"background_t.png",
"background_v.png"
};
// use random ETV color by default
string artworkPath = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
backgrounds[NextRandom(backgrounds.Length)]);
var boxBlur = false;
Option<int> randomColor = None;
const int HORIZONTAL_MARGIN_PERCENT = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
const int WATERMARK_WIDTH_PERCENT = 25;
ChannelWatermarkLocation watermarkLocation = NextRandom(2) == 0
? ChannelWatermarkLocation.BottomLeft
: ChannelWatermarkLocation.BottomRight;
foreach (SongMetadata metadata in song.SongMetadata)
{
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
var largeFontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 10.0);
bool detailsStyle = NextRandom(2) == 0;
var sb = new StringBuilder();
if (detailsStyle)
{
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append($"{{\\fs{largeFontSize}}}{metadata.Title}");
}
if (!string.IsNullOrWhiteSpace(metadata.Artist))
{
sb.Append($"\\N{{\\fs{fontSize}}}{metadata.Artist}");
}
}
else
{
if (!string.IsNullOrWhiteSpace(metadata.Artist))
{
sb.Append(metadata.Artist);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append($"\\N\"{metadata.Title}\"");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.Append($"\\N{metadata.Album}");
}
}
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
switch (watermarkLocation)
{
case ChannelWatermarkLocation.BottomLeft:
leftMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
case ChannelWatermarkLocation.BottomRight:
leftMarginPercent = rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
rightMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
}
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Width);
var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Width);
var verticalMargin = (int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * channel.FFmpegProfile.Resolution.Height);
subtitleFile = await new SubtitleBuilder(_tempFilePool)
.WithResolution(channel.FFmpegProfile.Resolution)
.WithFontName("OPTIKabel-Heavy")
.WithFontSize(fontSize)
.WithPrimaryColor("&HFFFFFF")
.WithOutlineColor("&H555555")
.WithAlignment(0)
.WithMarginRight(rightMargin)
.WithMarginLeft(leftMargin)
.WithMarginV(verticalMargin)
.WithBorderStyle(1)
.WithShadow(3)
.WithFormattedContent(sb.ToString())
.BuildFile();
// use thumbnail (cover art) if present
foreach (Artwork artwork in Optional(
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
{
int backgroundRoll = NextRandom(16);
if (backgroundRoll < 8)
{
randomColor = backgroundRoll;
}
else
{
boxBlur = true;
}
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
artworkPath = customPath;
// signal that we want to use cover art as watermark
videoVersion = new CoverArtMediaVersion
{
Chapters = new List<MediaChapter>(),
// always stretch cover art
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
}
}
string videoPath = artworkPath;
videoVersion.MediaFiles = new List<MediaFile>
{
new() { Path = videoPath }
};
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
subtitleFile,
channel,
maybeGlobalWatermark,
videoVersion,
videoPath,
boxBlur,
randomColor,
watermarkLocation,
HORIZONTAL_MARGIN_PERCENT,
VERTICAL_MARGIN_PERCENT,
WATERMARK_WIDTH_PERCENT);
foreach (string si in maybeSongImage.RightToSeq())
{
videoPath = si;
videoVersion = new BackgroundImageMediaVersion
{
Chapters = new List<MediaChapter>(),
// song image has been pre-generated with correct size
Height = channel.FFmpegProfile.Resolution.Height,
Width = channel.FFmpegProfile.Resolution.Width,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 },
},
MediaFiles = new List<MediaFile>
{
new() { Path = si }
}
};
}
return Tuple(videoPath, videoVersion);
}
private static int NextRandom(int max)
{
lock (RandomLock)
{
return Random.Next() % max;
}
}
}
}

138
ErsatzTV.Core/FFmpeg/SubtitleBuilder.cs

@ -0,0 +1,138 @@ @@ -0,0 +1,138 @@
using System.IO;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
public class SubtitleBuilder
{
private readonly ITempFilePool _tempFilePool;
private string _content;
private Option<IDisplaySize> _resolution = None;
private Option<string> _fontName;
private Option<int> _fontSize;
private Option<string> _primaryColor;
private Option<string> _outlineColor;
private Option<int> _alignment;
private int _marginRight;
private int _marginLeft;
private int _marginV;
private Option<int> _borderStyle;
private Option<int> _shadow;
public SubtitleBuilder(ITempFilePool tempFilePool)
{
_tempFilePool = tempFilePool;
}
public SubtitleBuilder WithResolution(IDisplaySize resolution)
{
_resolution = Some(resolution);
return this;
}
public SubtitleBuilder WithFontName(string fontName)
{
_fontName = fontName;
return this;
}
public SubtitleBuilder WithFontSize(int fontSize)
{
_fontSize = fontSize;
return this;
}
public SubtitleBuilder WithPrimaryColor(string primaryColor)
{
_primaryColor = primaryColor;
return this;
}
public SubtitleBuilder WithOutlineColor(string outlineColor)
{
_outlineColor = outlineColor;
return this;
}
public SubtitleBuilder WithAlignment(int alignment)
{
_alignment = alignment;
return this;
}
public SubtitleBuilder WithMarginRight(int marginRight)
{
_marginRight = marginRight;
return this;
}
public SubtitleBuilder WithMarginLeft(int marginLeft)
{
_marginLeft = marginLeft;
return this;
}
public SubtitleBuilder WithMarginV(int marginV)
{
_marginV = marginV;
return this;
}
public SubtitleBuilder WithBorderStyle(int borderStyle)
{
_borderStyle = borderStyle;
return this;
}
public SubtitleBuilder WithShadow(int shadow)
{
_shadow = shadow;
return this;
}
public SubtitleBuilder WithFormattedContent(string content)
{
_content = content;
return this;
}
public async Task<string> BuildFile()
{
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
var sb = new StringBuilder();
sb.AppendLine("[Script Info]");
sb.AppendLine("ScriptType: v4.00+");
sb.AppendLine("WrapStyle: 0");
sb.AppendLine("ScaledBorderAndShadow: yes");
sb.AppendLine("YCbCr Matrix: None");
foreach (IDisplaySize resolution in _resolution)
{
sb.AppendLine($"PlayResX: {resolution.Width}");
sb.AppendLine($"PlayResY: {resolution.Height}");
}
sb.AppendLine("[V4+ Styles]");
sb.AppendLine("Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BorderStyle, Shadow, Alignment, Encoding");
sb.AppendLine($"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},{await _shadow.IfNoneAsync(0)}, {await _alignment.IfNoneAsync(0)},1");
sb.AppendLine("[Events]");
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
sb.AppendLine($"Dialogue: 0,0:00:00.00,99:99:99.99,Default,,{_marginLeft},{_marginRight},{_marginV},,{_content}");
if (!string.IsNullOrWhiteSpace(_content))
{
sb.AppendLine(_content);
}
await File.WriteAllTextAsync(fileName, sb.ToString());
return fileName;
}
}
}

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

@ -46,6 +46,12 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg @@ -46,6 +46,12 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath);
string videoPath,
bool boxBlur,
Option<int> randomColor,
ChannelWatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent);
}
}

16
ErsatzTV.Core/Interfaces/FFmpeg/ISongVideoGenerator.cs

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface ISongVideoGenerator
{
Task<Tuple<string, MediaVersion>> GenerateSongVideo(
Song song,
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath);
}
}

1
ErsatzTV/Startup.cs

@ -311,6 +311,7 @@ namespace ErsatzTV @@ -311,6 +311,7 @@ namespace ErsatzTV
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<IFFmpegProcessService, FFmpegProcessService>();
services.AddScoped<ISongVideoGenerator, SongVideoGenerator>();
services.AddScoped<HlsSessionWorker>();
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(

Loading…
Cancel
Save