Files
ersatztv/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs
T
timothyandClaude Opus 5 dd7b58232c
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 59s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m54s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
review-verdict/h10 Review-verdict: MERGEABLE @ dd7b582 (base: main)
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 22m12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review verdict / Set review-verdict status (pull_request) Successful in 5s
PR Gates / decisions lifecycle (pull_request) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 46s
fix(691): revert entity-level null guard, guard read sites instead
The prior commit (a4700185b) made SongMetadata.Artists/AlbumArtists
coalesce null to [] via backing-field getters, reasoning that EF
Core's PreferField access mode never observes the getter. Adversarial
review disproved this on real TvContext/SQLite: a single read of
.Artists on a TRACKED entity mutates the backing field through the
getter, flips the entity to Modified, and the next SaveChanges writes
[] over what was a NULL column -- silent data loss waiting on the
first tracked reader (today all readers happen to be AsNoTracking).

This also reversed docs/decisions/records/api/selection-projection-include-chain.md
(#671) without the doc update CLAUDE.md requires; #691 is that
record's own "sweep by FIELD" follow-up, so it should follow the
record, not contradict it.

Revert SongMetadata.cs to plain auto-properties (byte-identical to
origin/main, BOM still stripped per the #311 gate). Guard the read
sites instead, per the #671 convention (Optional(...).Flatten(),
matching Playouts/Mapper.cs and MediaItems/Mapper.cs):

- SongVideoGenerator.cs: hoist `artists`/`albumArtists` locals once
  near the top of the metadata loop instead of repeating the guard at
  each of the six former call sites.
- MediaCollectionRepository.cs (GroupIntoFakeCollections): guard the
  two AlbumArtists reads at lines ~1147/~1160 that #691 never named --
  dropping the entity-level fix without these would trade one bug for
  two.

Verified RED per guard by removing only the Optional(...).Flatten()
clause (not the whole file): the artists local throws
ArgumentNullException at SongVideoGenerator.cs:88, the albumArtists
local at :89 (List.ToList() on a null IList<string> source -- same
loaded-gun shape the review demonstrated, precise exception type is
ArgumentNullException rather than NullReferenceException since the
throw site is Enumerable.ToList's null-source check). Restored both;
existing SongVideoGeneratorTests still pass. Full ErsatzTV.Core.Tests:
685 passed (1 pre-existing skip), ErsatzTV.Tests: 1996 passed (4
pre-existing skips), 0 failures in each. No EF model drift
(`dotnet ef migrations has-pending-model-changes` reports none).
`dotnet format --verify-no-changes` on the three touched files exits
0.

Refs #691

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:39:52 +02:00

264 lines
9.4 KiB
C#

using System.Globalization;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.Core.FFmpeg;
public class SongVideoGenerator : ISongVideoGenerator
{
private static readonly Random Random = new();
private static readonly Lock RandomLock = new();
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly ILocalFileSystem _localFileSystem;
private readonly IImageCache _imageCache;
private readonly ITempFilePool _tempFilePool;
public SongVideoGenerator(
ITempFilePool tempFilePool,
IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem)
{
_tempFilePool = tempFilePool;
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
_localFileSystem = localFileSystem;
}
public async Task<Tuple<string, MediaVersion>> GenerateSongVideo(
Song song,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
Option<string> subtitleFile = None;
MediaVersion videoVersion = new FallbackMediaVersion
{
Id = -1,
Chapters = [],
Width = channel.FFmpegProfile.Resolution.Width / 10,
Height = channel.FFmpegProfile.Resolution.Height / 10,
SampleAspectRatio = "1:1",
Streams = [new MediaStream { MediaStreamKind = MediaStreamKind.Video, Index = 0, PixelFormat = "yuv420p" }]
};
string[] backgrounds =
[
"song_background_1.png",
"song_background_2.png",
"song_background_3.png"
];
// use random ETV color by default
string backgroundPath = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ResourcesCacheFolder,
backgrounds[NextRandom(backgrounds.Length)]);
Option<string> watermarkPath = None;
var boxBlur = false;
const int HORIZONTAL_MARGIN_PERCENT = 3;
var verticalMarginPercent = 5;
const int WATERMARK_WIDTH_PERCENT = 25;
WatermarkLocation watermarkLocation = NextRandom(2) == 0
? WatermarkLocation.BottomLeft
: WatermarkLocation.BottomRight;
if (channel.SongVideoMode is ChannelSongVideoMode.WithProgress)
{
verticalMarginPercent += 10;
}
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();
List<string> artists = Optional(metadata.Artists).Flatten().ToList();
List<string> albumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
if (detailsStyle)
{
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append(CultureInfo.InvariantCulture, $"{{\\fs{largeFontSize}}}{metadata.Title}");
}
if (artists.Count > 0)
{
var allArtists = string.Join(", ", artists);
sb.Append(CultureInfo.InvariantCulture, $"\\N{{\\fs{fontSize}}}{allArtists}");
}
}
else
{
if (artists.Count > 0)
{
var allArtists = string.Join(", ", artists);
sb.Append(allArtists);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append(CultureInfo.InvariantCulture, $"\\N\"{metadata.Title}\"");
}
if (albumArtists.Count > 0)
{
var allAlbumArtists = string.Join(
", ",
albumArtists.Filter(aa => !artists.Contains(aa)));
sb.Append(CultureInfo.InvariantCulture, $"\\N{allAlbumArtists}");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.Append(CultureInfo.InvariantCulture, $"\\N{metadata.Album}");
}
}
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
switch (watermarkLocation)
{
case WatermarkLocation.BottomLeft:
leftMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
case WatermarkLocation.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(verticalMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Height);
subtitleFile = await new SubtitleBuilder(_tempFilePool)
.WithResolution(channel.FFmpegProfile.Resolution)
.WithFontName("OPTIKabel-Heavy")
.WithFontSize(fontSize)
.WithPrimaryColor("&HFFFFFF")
.WithOutlineColor("&H444444")
.WithAlignment(0)
.WithMarginRight(rightMargin)
.WithMarginLeft(leftMargin)
.WithMarginV(verticalMargin)
.WithBorderStyle(1)
.WithShadow(3)
.WithFormattedContent(sb.ToString())
.BuildFile();
// use thumbnail (cover art) if present
// fall back to default art
Artwork artwork = await Optional(metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail))
.IfNoneAsync(
new Artwork
{
Id = 0,
ArtworkKind = ArtworkKind.Thumbnail,
Path = _localFileSystem.GetCustomOrDefaultFile(FileSystemLayout.ResourcesCacheFolder, "song_album_cover_512.png")
});
// signal that we want to use cover art as watermark
videoVersion = new CoverArtMediaVersion
{
Chapters = [],
// always stretch cover art
Width = channel.FFmpegProfile.Resolution.Width / 10,
Height = channel.FFmpegProfile.Resolution.Height / 10,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
watermarkPath = customPath;
// only blurhash real album art
if (artwork.Id > 0)
{
// randomize selected blur hash
var hashes = new List<string>
{
artwork.BlurHash43,
artwork.BlurHash54,
artwork.BlurHash64
}.Filter(s => !string.IsNullOrWhiteSpace(s)).ToList();
if (hashes.Count != 0)
{
string hash = hashes[NextRandom(hashes.Count)];
backgroundPath = await _imageCache.WriteBlurHash(hash, channel.FFmpegProfile.Resolution);
videoVersion.Height = channel.FFmpegProfile.Resolution.Height;
videoVersion.Width = channel.FFmpegProfile.Resolution.Width;
}
else
{
backgroundPath = customPath;
boxBlur = true;
}
}
}
string videoPath = backgroundPath;
videoVersion.MediaFiles = [new MediaFile { Path = videoPath }];
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
ffprobePath,
subtitleFile,
channel,
videoVersion,
videoPath,
boxBlur,
watermarkPath,
watermarkLocation,
HORIZONTAL_MARGIN_PERCENT,
verticalMarginPercent,
WATERMARK_WIDTH_PERCENT,
cancellationToken);
foreach (string si in maybeSongImage.RightToSeq())
{
videoPath = si;
videoVersion = BackgroundImageMediaVersion.ForPath(
si,
channel.FFmpegProfile.Resolution,
channel.SongVideoMode is ChannelSongVideoMode.WithProgress);
}
return Tuple(videoPath, videoVersion);
}
private static int NextRandom(int max)
{
lock (RandomLock)
{
return Random.Next() % max;
}
}
}