mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* refactor song background logic * move song video generation * move subtitle generation * build ASS subtitles * randomize song detail layout * update changelogpull/512/head
10 changed files with 463 additions and 197 deletions
@ -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; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue