Browse Source

support multiple watermarks in yaml schedules (#2267)

* add multiple watermarks per playout item

* fixes

* update yaml playout watermark to support multiple watermarks

* use graphics engine for intermittent watermarks
pull/2269/head
Jason Dove 1 year ago committed by GitHub
parent
commit
5a5c049835
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      CHANGELOG.md
  2. 8
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  3. 1
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  4. 20
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  5. 8
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  6. 2
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  7. 7
      ErsatzTV.Core/Domain/PlayoutItem.cs
  8. 9
      ErsatzTV.Core/Domain/PlayoutItemWatermark.cs
  9. 163
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  10. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  11. 11
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  12. 7
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
  13. 7
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs
  14. 7
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs
  15. 7
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs
  16. 12
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs
  17. 14
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs
  18. 12
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs
  19. 41
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs
  20. 35
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs
  21. 6223
      ErsatzTV.Infrastructure.MySql/Migrations/20250806191133_Add_PlayoutItemWatermark.Designer.cs
  22. 51
      ErsatzTV.Infrastructure.MySql/Migrations/20250806191133_Add_PlayoutItemWatermark.cs
  23. 6223
      ErsatzTV.Infrastructure.MySql/Migrations/20250806191615_Populate_PlayoutItemWatermark.Designer.cs
  24. 23
      ErsatzTV.Infrastructure.MySql/Migrations/20250806191615_Populate_PlayoutItemWatermark.cs
  25. 6211
      ErsatzTV.Infrastructure.MySql/Migrations/20250806195038_Remove_PlayoutItemWatermark.Designer.cs
  26. 49
      ErsatzTV.Infrastructure.MySql/Migrations/20250806195038_Remove_PlayoutItemWatermark.cs
  27. 50
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  28. 6060
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191226_Add_PlayoutItemWatermarkTable.Designer.cs
  29. 50
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191226_Add_PlayoutItemWatermarkTable.cs
  30. 6060
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191314_Populate_PlayoutItemWatermark.Designer.cs
  31. 23
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191314_Populate_PlayoutItemWatermark.cs
  32. 6048
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806195120_Remove_PlayoutItemWatermark.Designer.cs
  33. 49
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806195120_Remove_PlayoutItemWatermark.cs
  34. 50
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  35. 17
      ErsatzTV.Infrastructure/Data/Configurations/PlayoutItemConfiguration.cs
  36. 2
      ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs
  37. 17
      ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs
  38. 4
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  39. 18
      ErsatzTV/Pages/YamlValidator.razor
  40. 15
      ErsatzTV/Resources/yaml-playout-import.schema.json
  41. 15
      ErsatzTV/Resources/yaml-playout.schema.json

11
CHANGELOG.md

@ -6,9 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Add *experimental* graphics engine - Add *experimental* graphics engine
- `Permanent` watermarks will use new graphics engine - All watermarks will use new graphics engine
- `Opacity Expression` watermarks will use new graphics engine
- `Intermittent` watermarks will still use normal overlay pipeline (for now)
- Add `Opacity Expression` watermark mode - Add `Opacity Expression` watermark mode
- This allows specifying an expression that returns an opacity between 0.0 and 1.0 - This allows specifying an expression that returns an opacity between 0.0 and 1.0
- The expression can use: - The expression can use:
@ -16,6 +14,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `channel_seconds` - the total number of seconds the frame is from when the channel started/activated - `channel_seconds` - the total number of seconds the frame is from when the channel started/activated
- `time_of_day_seconds` - the total number of seconds the frame is since midnight - `time_of_day_seconds` - the total number of seconds the frame is since midnight
### Changed
- Allow multiple watermarks on a single playout item
- YAML playout: `watermark` instruction changes:
- When value is `true`, will add named watermark to list of active watermarks
- When value is `false` and `name` is specified, will remove named watermark from list of active watermarks
- When value is `false` and `name` is not specified, will clear all active watermarks
## [25.4.0] - 2025-08-05 ## [25.4.0] - 2025-08-05
### Added ### Added
- Add `Troubleshoot Playback` to overflow menu on all media cards - Add `Troubleshoot Playback` to overflow menu on all media cards

8
ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs

@ -166,6 +166,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.Include(p => p.Channel) .Include(p => p.Channel)
.Include(p => p.Deco) .Include(p => p.Deco)
.Include(p => p.Items) .Include(p => p.Items)
.ThenInclude(pi => pi.Watermarks)
.Include(p => p.PlayoutHistory) .Include(p => p.PlayoutHistory)
.Include(p => p.Templates) .Include(p => p.Templates)
.ThenInclude(t => t.Template) .ThenInclude(t => t.Template)
@ -181,6 +182,10 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.Include(p => p.ProgramScheduleAlternates) .Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule) .ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items) .ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Watermark)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection) .ThenInclude(psi => psi.Collection)
.Include(p => p.ProgramScheduleAlternates) .Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule) .ThenInclude(a => a.ProgramSchedule)
@ -212,6 +217,9 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.ThenInclude(a => a.MediaItem) .ThenInclude(a => a.MediaItem)
.Include(p => p.ProgramSchedule) .Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items) .ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Watermark)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection) .ThenInclude(psi => psi.Collection)
.Include(p => p.ProgramSchedule) .Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items) .ThenInclude(ps => ps.Items)

1
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -467,6 +467,7 @@ public class HlsSessionWorker : IHlsSessionWorker
foreach (var graphicsEngineContext in processModel.GraphicsEngineContext) foreach (var graphicsEngineContext in processModel.GraphicsEngineContext)
{ {
var pipe = new Pipe(); var pipe = new Pipe();
maybePipe = pipe;
processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(pipe.Reader.AsStream())); processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(pipe.Reader.AsStream()));
// fire and forget graphics engine task // fire and forget graphics engine task

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

@ -164,7 +164,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata) .ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(i => i.Watermark) .Include(i => i.Watermarks)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).MediaVersions) .ThenInclude(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
@ -173,7 +173,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata) .ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Include(i => i.Watermark) .Include(i => i.Watermarks)
.ForChannelAndTime(channel.Id, now) .ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem())) .Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(item => ValidatePlayoutItemPath(dbContext, item)); .BindT(item => ValidatePlayoutItemPath(dbContext, item));
@ -242,7 +242,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.BindT(watermarkId => dbContext.ChannelWatermarks .BindT(watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId)); .SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
Option<ChannelWatermark> playoutItemWatermark = Optional(playoutItemWithPath.PlayoutItem.Watermark); List<ChannelWatermark> playoutItemWatermarks = [];
playoutItemWatermarks.AddRange(playoutItemWithPath.PlayoutItem.Watermarks);
bool disableWatermarks = playoutItemWithPath.PlayoutItem.DisableWatermarks; bool disableWatermarks = playoutItemWithPath.PlayoutItem.DisableWatermarks;
WatermarkResult watermarkResult = GetPlayoutItemWatermark(playoutItemWithPath.PlayoutItem, now); WatermarkResult watermarkResult = GetPlayoutItemWatermark(playoutItemWithPath.PlayoutItem, now);
switch (watermarkResult) switch (watermarkResult)
@ -254,7 +256,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
disableWatermarks = true; disableWatermarks = true;
break; break;
case CustomWatermark watermark: case CustomWatermark watermark:
playoutItemWatermark = watermark.Watermark; playoutItemWatermarks.Clear();
playoutItemWatermarks.Add(watermark.Watermark);
break; break;
} }
@ -263,7 +266,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo( (videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song, song,
channel, channel,
playoutItemWatermark, playoutItemWatermarks.HeadOrNone(),
maybeGlobalWatermark, maybeGlobalWatermark,
ffmpegPath, ffmpegPath,
ffprobePath, ffprobePath,
@ -278,7 +281,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png"; string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png";
disableWatermarks = false; disableWatermarks = false;
playoutItemWatermark = new ChannelWatermark playoutItemWatermarks.Clear();
playoutItemWatermarks.Add(new ChannelWatermark
{ {
Mode = ChannelWatermarkMode.Permanent, Mode = ChannelWatermarkMode.Permanent,
Size = WatermarkSize.Scaled, Size = WatermarkSize.Scaled,
@ -289,7 +293,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Location = WatermarkLocation.TopLeft, Location = WatermarkLocation.TopLeft,
ImageSource = ChannelWatermarkImageSource.Resource, ImageSource = ChannelWatermarkImageSource.Resource,
Image = image Image = image
}; });
} }
} }
@ -335,7 +339,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
start, start,
finish, finish,
effectiveNow, effectiveNow,
playoutItemWatermark, playoutItemWatermarks,
maybeGlobalWatermark, maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDisplay, channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDriver,

8
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -78,11 +78,11 @@ public class PrepareTroubleshootingPlaybackHandler(
return BaseError.New("Media item does not exist on disk"); return BaseError.New("Media item does not exist on disk");
} }
Option<ChannelWatermark> maybeWatermark = Option<ChannelWatermark>.None; List<ChannelWatermark> watermarks = [];
if (request.WatermarkId > 0) if (request.WatermarkId > 0)
{ {
maybeWatermark = await dbContext.ChannelWatermarks watermarks.AddRange(await dbContext.ChannelWatermarks
.SelectOneAsync(cw => cw.Id, cw => cw.Id == request.WatermarkId); .SelectOneAsync(cw => cw.Id, cw => cw.Id == request.WatermarkId));
} }
DateTimeOffset now = DateTimeOffset.Now; DateTimeOffset now = DateTimeOffset.Now;
@ -132,7 +132,7 @@ public class PrepareTroubleshootingPlaybackHandler(
now, now,
now + duration, now + duration,
now, now,
maybeWatermark, watermarks,
Option<ChannelWatermark>.None, Option<ChannelWatermark>.None,
ffmpegProfile.VaapiDisplay, ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver, ffmpegProfile.VaapiDriver,

2
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -20,6 +20,8 @@ public class ChannelWatermark
public int Opacity { get; set; } public int Opacity { get; set; }
public bool PlaceWithinSourceContent { get; set; } public bool PlaceWithinSourceContent { get; set; }
public string OpacityExpression { get; set; } public string OpacityExpression { get; set; }
public List<PlayoutItem> PlayoutItems { get; set; }
public List<PlayoutItemWatermark> PlayoutItemWatermarks { get; set; }
} }
public enum ChannelWatermarkMode public enum ChannelWatermarkMode

7
ErsatzTV.Core/Domain/PlayoutItem.cs

@ -22,8 +22,7 @@ public class PlayoutItem
public TimeSpan InPoint { get; set; } public TimeSpan InPoint { get; set; }
public TimeSpan OutPoint { get; set; } public TimeSpan OutPoint { get; set; }
public string ChapterTitle { get; set; } public string ChapterTitle { get; set; }
public ChannelWatermark Watermark { get; set; } public List<ChannelWatermark> Watermarks { get; set; }
public int? WatermarkId { get; set; }
public bool DisableWatermarks { get; set; } public bool DisableWatermarks { get; set; }
public string PreferredAudioLanguageCode { get; set; } public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; } public string PreferredAudioTitle { get; set; }
@ -32,6 +31,7 @@ public class PlayoutItem
public string BlockKey { get; set; } public string BlockKey { get; set; }
public string CollectionKey { get; set; } public string CollectionKey { get; set; }
public string CollectionEtag { get; set; } public string CollectionEtag { get; set; }
public List<PlayoutItemWatermark> PlayoutItemWatermarks { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime(); public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime(); public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
@ -55,8 +55,7 @@ public class PlayoutItem
InPoint = chapter.StartTime, InPoint = chapter.StartTime,
OutPoint = chapter.EndTime, OutPoint = chapter.EndTime,
ChapterTitle = chapter.Title, ChapterTitle = chapter.Title,
Watermark = Watermark, Watermarks = Watermarks,
WatermarkId = WatermarkId,
DisableWatermarks = DisableWatermarks, DisableWatermarks = DisableWatermarks,
PreferredAudioLanguageCode = PreferredAudioLanguageCode, PreferredAudioLanguageCode = PreferredAudioLanguageCode,
PreferredAudioTitle = PreferredAudioTitle, PreferredAudioTitle = PreferredAudioTitle,

9
ErsatzTV.Core/Domain/PlayoutItemWatermark.cs

@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public class PlayoutItemWatermark
{
public int PlayoutItemId { get; set; }
public PlayoutItem PlayoutItem { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
}

163
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -62,7 +62,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset start, DateTimeOffset start,
DateTimeOffset finish, DateTimeOffset finish,
DateTimeOffset now, DateTimeOffset now,
Option<ChannelWatermark> playoutItemWatermark, List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark, Option<ChannelWatermark> globalWatermark,
string vaapiDisplay, string vaapiDisplay,
VaapiDriver vaapiDriver, VaapiDriver vaapiDriver,
@ -157,30 +157,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
} }
} }
Option<WatermarkOptions> watermarkOptions = disableWatermarks
? None
: await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
None,
None);
Option<List<FadePoint>> maybeFadePoints = watermarkOptions
.Map(o => o.Watermark)
.Flatten()
.Where(wm => wm.Mode == ChannelWatermarkMode.Intermittent)
.Map(wm =>
WatermarkCalculator.CalculateFadePoints(
start,
inPoint,
outPoint,
playbackSettings.StreamSeek,
wm.FrequencyMinutes,
wm.DurationSeconds));
string audioFormat = playbackSettings.AudioFormat switch string audioFormat = playbackSettings.AudioFormat switch
{ {
FFmpegProfileAudioFormat.Aac => AudioFormat.Aac, FFmpegProfileAudioFormat.Aac => AudioFormat.Aac,
@ -345,31 +321,67 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
method); method);
}).Flatten(); }).Flatten();
Option<WatermarkInputFile> watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints); Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None;
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None; Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None; Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
// use graphics engine for permanent watermarks, or opacity expressions // use graphics engine for all watermarks
var maybeGraphicsEngineWatermark = watermarkOptions if (!disableWatermarks)
.Where(o => o.Watermark
.Map(wm => wm.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.OpacityExpression)
.IfNone(false));
foreach (var options in maybeGraphicsEngineWatermark)
{ {
watermarkInputFile = Option<WatermarkInputFile>.None; var watermarks = new Dictionary<int, WatermarkElementContext>();
// still need channel and global watermarks
if (playoutItemWatermarks.Count == 0)
{
WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
Option<ChannelWatermark>.None,
globalWatermark,
videoVersion,
None,
None);
graphicsEngineInput = new GraphicsEngineInput(); foreach (var watermark in options.Watermark)
{
// don't allow duplicates
watermarks.TryAdd(watermark.Id, new WatermarkElementContext(options));
}
}
// load all playout item watermarks
foreach (var playoutItemWatermark in playoutItemWatermarks)
{
WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
None,
None);
WatermarkElementContext watermark = new WatermarkElementContext(options); foreach (var watermark in options.Watermark)
{
// don't allow duplicates
watermarks.TryAdd(watermark.Id, new WatermarkElementContext(options));
}
}
graphicsEngineContext = new GraphicsEngineContext( // only use graphics engine when we have watermarks
[watermark], if (watermarks.Count > 0)
channel.FFmpegProfile.Resolution, {
await playbackSettings.FrameRate.IfNoneAsync(24), graphicsEngineInput = new GraphicsEngineInput();
ChannelStartTime: channelStartTime,
ContentStartTime: start, graphicsEngineContext = new GraphicsEngineContext(
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero), watermarks.Values.OfType<GraphicsElementContext>().ToList(),
finish - now); channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24),
ChannelStartTime: channelStartTime,
ContentStartTime: start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now);
}
} }
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind); HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind);
@ -1014,71 +1026,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false); return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false);
} }
private static Option<WatermarkInputFile> GetWatermarkInputFile(
Option<WatermarkOptions> watermarkOptions,
Option<List<FadePoint>> maybeFadePoints)
{
foreach (WatermarkOptions options in watermarkOptions)
{
foreach (ChannelWatermark watermark in options.Watermark)
{
// skip watermark if intermittent and no fade points
if (watermark.Mode != ChannelWatermarkMode.None &&
(watermark.Mode != ChannelWatermarkMode.Intermittent ||
maybeFadePoints.Map(fp => fp.Count > 0).IfNone(false)))
{
foreach (string path in options.ImagePath)
{
var watermarkInputFile = new WatermarkInputFile(
path,
new List<VideoStream>
{
new(
options.ImageStreamIndex.IfNone(0),
"unknown",
string.Empty,
new PixelFormatUnknown(),
ColorParams.Default,
new FrameSize(1, 1),
string.Empty,
string.Empty,
Option<string>.None,
!options.IsAnimated,
ScanKind.Progressive)
},
new WatermarkState(
maybeFadePoints.Map(lst => lst.Map(fp =>
{
return fp switch
{
FadeInPoint fip => (WatermarkFadePoint)new WatermarkFadeIn(
fip.Time,
fip.EnableStart,
fip.EnableFinish),
FadeOutPoint fop => new WatermarkFadeOut(
fop.Time,
fop.EnableStart,
fop.EnableFinish),
_ => throw new NotSupportedException() // this will never happen
};
}).ToList()),
watermark.Location,
watermark.Size,
watermark.WidthPercent,
watermark.HorizontalMarginPercent,
watermark.VerticalMarginPercent,
watermark.Opacity,
watermark.PlaceWithinSourceContent));
return watermarkInputFile;
}
}
}
}
return None;
}
private Command GetCommand( private Command GetCommand(
string ffmpegPath, string ffmpegPath,
Option<VideoInputFile> videoInputFile, Option<VideoInputFile> videoInputFile,

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

@ -26,7 +26,7 @@ public interface IFFmpegProcessService
DateTimeOffset start, DateTimeOffset start,
DateTimeOffset finish, DateTimeOffset finish,
DateTimeOffset now, DateTimeOffset now,
Option<ChannelWatermark> playoutItemWatermark, List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark, Option<ChannelWatermark> globalWatermark,
string vaapiDisplay, string vaapiDisplay,
VaapiDriver vaapiDriver, VaapiDriver vaapiDriver,

11
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -426,10 +426,13 @@ public class PlayoutBuilder : IPlayoutBuilder
// so only log the warning, and leave the bad data to fail tests // so only log the warning, and leave the bad data to fail tests
// playout.Items.Remove(futureItem); // playout.Items.Remove(futureItem);
_logger.LogInformation( if (futureItemCount > 0)
"{Count} playout items are scheduled after hard stop of {HardStop}; this is expected if duration is used.", {
futureItemCount, _logger.LogInformation(
trimAfter); "{Count} playout items are scheduled after hard stop of {HardStop}; this is expected if duration is used.",
futureItemCount,
trimAfter);
}
} }
return playout; return playout;

7
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs

@ -157,13 +157,18 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
? FillerKind.GuideMode ? FillerKind.GuideMode
: FillerKind.None, : FillerKind.None,
CustomTitle = scheduleItem.CustomTitle, CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle, PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode SubtitleMode = scheduleItem.SubtitleMode
}; };
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime); durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc); DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc);

7
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs

@ -76,13 +76,18 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
? FillerKind.GuideMode ? FillerKind.GuideMode
: FillerKind.None, : FillerKind.None,
CustomTitle = scheduleItem.CustomTitle, CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle, PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode SubtitleMode = scheduleItem.SubtitleMode
}; };
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
var enumeratorStates = new Dictionary<CollectionKey, CollectionEnumeratorState>(); var enumeratorStates = new Dictionary<CollectionKey, CollectionEnumeratorState>();
foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators) foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{ {

7
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs

@ -100,13 +100,18 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
? FillerKind.GuideMode ? FillerKind.GuideMode
: FillerKind.None, : FillerKind.None,
CustomTitle = scheduleItem.CustomTitle, CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle, PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode SubtitleMode = scheduleItem.SubtitleMode
}; };
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime); // LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
playoutItems.AddRange( playoutItems.AddRange(

7
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs

@ -47,13 +47,18 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
? FillerKind.GuideMode ? FillerKind.GuideMode
: FillerKind.None, : FillerKind.None,
CustomTitle = scheduleItem.CustomTitle, CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle, PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode SubtitleMode = scheduleItem.SubtitleMode
}; };
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
List<PlayoutItem> playoutItems = AddFiller( List<PlayoutItem> playoutItems = AddFiller(
playoutBuilderState, playoutBuilderState,
collectionEnumerators, collectionEnumerators,

12
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs

@ -58,17 +58,23 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle, //PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, //PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode //SubtitleMode = scheduleItem.SubtitleMode
GuideGroup = context.PeekNextGuideGroup() GuideGroup = context.PeekNextGuideGroup(),
//GuideStart = effectiveBlock.Start.UtcDateTime, //GuideStart = effectiveBlock.Start.UtcDateTime,
//GuideFinish = blockFinish.UtcDateTime, //GuideFinish = blockFinish.UtcDateTime,
//BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey), //BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
//CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings), //CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
//CollectionEtag = collectionEtags[collectionKey] //CollectionEtag = collectionEtags[collectionKey]
PlayoutItemWatermarks = []
}; };
foreach (int watermarkId in context.GetChannelWatermarkId()) foreach (int watermarkId in context.GetChannelWatermarkIds())
{ {
playoutItem.WatermarkId = watermarkId; playoutItem.PlayoutItemWatermarks.Add(
new PlayoutItemWatermark
{
PlayoutItem = playoutItem,
WatermarkId = watermarkId
});
} }
await AddItemAndMidRoll(context, playoutItem, mediaItem, executeSequence); await AddItemAndMidRoll(context, playoutItem, mediaItem, executeSequence);

14
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs

@ -83,17 +83,23 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle, //PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, //PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode //SubtitleMode = scheduleItem.SubtitleMode
GuideGroup = context.PeekNextGuideGroup() GuideGroup = context.PeekNextGuideGroup(),
//GuideStart = effectiveBlock.Start.UtcDateTime, //GuideStart = effectiveBlock.Start.UtcDateTime,
//GuideFinish = blockFinish.UtcDateTime, //GuideFinish = blockFinish.UtcDateTime,
//BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey), //BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
//CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings), //CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
//CollectionEtag = collectionEtags[collectionKey] //CollectionEtag = collectionEtags[collectionKey],
PlayoutItemWatermarks = []
}; };
foreach (int watermarkId in context.GetChannelWatermarkId()) foreach (int watermarkId in context.GetChannelWatermarkIds())
{ {
playoutItem.WatermarkId = watermarkId; playoutItem.PlayoutItemWatermarks.Add(
new PlayoutItemWatermark
{
PlayoutItem = playoutItem,
WatermarkId = watermarkId
});
} }
await AddItemAndMidRoll(context, playoutItem, mediaItem, executeSequence); await AddItemAndMidRoll(context, playoutItem, mediaItem, executeSequence);

12
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs

@ -122,12 +122,18 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
GuideGroup = context.PeekNextGuideGroup(), GuideGroup = context.PeekNextGuideGroup(),
FillerKind = fillerKind, FillerKind = fillerKind,
CustomTitle = string.IsNullOrWhiteSpace(customTitle) ? null : customTitle, CustomTitle = string.IsNullOrWhiteSpace(customTitle) ? null : customTitle,
DisableWatermarks = disableWatermarks DisableWatermarks = disableWatermarks,
PlayoutItemWatermarks = []
}; };
foreach (int watermarkId in context.GetChannelWatermarkId()) foreach (int watermarkId in context.GetChannelWatermarkIds())
{ {
playoutItem.WatermarkId = watermarkId; playoutItem.PlayoutItemWatermarks.Add(
new PlayoutItemWatermark
{
PlayoutItem = playoutItem,
WatermarkId = watermarkId
});
} }
if (remainingToFill - itemDuration >= TimeSpan.Zero || !stopBeforeEnd) if (remainingToFill - itemDuration >= TimeSpan.Zero || !stopBeforeEnd)

41
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs

@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) : IYamlPlayoutHandler public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) : IYamlPlayoutHandler
{ {
private readonly Dictionary<string, Option<ChannelWatermark>> _watermarkCache = new();
public bool Reset => false; public bool Reset => false;
public async Task<bool> Handle( public async Task<bool> Handle(
@ -24,17 +26,48 @@ public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) :
if (watermark.Watermark && !string.IsNullOrWhiteSpace(watermark.Name)) if (watermark.Watermark && !string.IsNullOrWhiteSpace(watermark.Name))
{ {
Option<ChannelWatermark> maybeWatermark = await channelRepository.GetWatermarkByName(watermark.Name); foreach (var wm in await GetChannelWatermarkByName(watermark.Name))
foreach (ChannelWatermark channelWatermark in maybeWatermark)
{ {
context.SetChannelWatermarkId(channelWatermark.Id); context.SetChannelWatermarkId(wm.Id);
} }
} }
else else
{ {
context.ClearChannelWatermarkId(); if (!string.IsNullOrWhiteSpace(watermark.Name))
{
foreach (var wm in await GetChannelWatermarkByName(watermark.Name))
{
context.RemoveChannelWatermarkId(wm.Id);
}
}
else
{
context.ClearChannelWatermarkIds();
}
} }
return true; return true;
} }
private async Task<Option<ChannelWatermark>> GetChannelWatermarkByName(string name)
{
if (_watermarkCache.TryGetValue(name, out var cachedWatermark))
{
foreach (ChannelWatermark channelWatermark in cachedWatermark)
{
return channelWatermark;
}
}
else
{
Option<ChannelWatermark> maybeWatermark = await channelRepository.GetWatermarkByName(name);
_watermarkCache.Add(name, maybeWatermark);
foreach (ChannelWatermark channelWatermark in maybeWatermark)
{
return channelWatermark;
}
}
return Option<ChannelWatermark>.None;
}
} }

35
ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs

@ -14,10 +14,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
private readonly System.Collections.Generic.HashSet<int> _visitedInstructions = []; private readonly System.Collections.Generic.HashSet<int> _visitedInstructions = [];
private readonly Stack<FillerKind> _fillerKind = new(); private readonly Stack<FillerKind> _fillerKind = new();
private readonly System.Collections.Generic.HashSet<int> _channelWatermarkIds = [];
private int _guideGroup = guideGroup; private int _guideGroup = guideGroup;
private bool _guideGroupLocked; private bool _guideGroupLocked;
private int _instructionIndex; private int _instructionIndex;
private Option<int> _channelWatermarkId;
private Option<string> _preRollSequence; private Option<string> _preRollSequence;
private Option<string> _postRollSequence; private Option<string> _postRollSequence;
private Option<MidRollSequence> _midRollSequence; private Option<MidRollSequence> _midRollSequence;
@ -84,15 +84,20 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
public void SetChannelWatermarkId(int id) public void SetChannelWatermarkId(int id)
{ {
_channelWatermarkId = id; _channelWatermarkIds.Add(id);
} }
public void ClearChannelWatermarkId() public void RemoveChannelWatermarkId(int id)
{ {
_channelWatermarkId = Option<int>.None; _channelWatermarkIds.Remove(id);
} }
public Option<int> GetChannelWatermarkId() => _channelWatermarkId; public void ClearChannelWatermarkIds()
{
_channelWatermarkIds.Clear();
}
public List<int> GetChannelWatermarkIds() => _channelWatermarkIds.ToList();
public void SetPreRollSequence(string sequence) public void SetPreRollSequence(string sequence)
{ {
@ -142,19 +147,19 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
public string Serialize() public string Serialize()
{ {
int? channelWatermarkId = null;
foreach (int id in _channelWatermarkId)
{
channelWatermarkId = id;
}
string preRollSequence = null; string preRollSequence = null;
foreach (string sequence in _preRollSequence) foreach (string sequence in _preRollSequence)
{ {
preRollSequence = sequence; preRollSequence = sequence;
} }
var state = new State(_instructionIndex, _guideGroup, _guideGroupLocked, channelWatermarkId, preRollSequence); var state = new State(
_instructionIndex,
_guideGroup,
_guideGroupLocked,
_channelWatermarkIds.ToList(),
preRollSequence);
return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings); return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings);
} }
@ -184,9 +189,9 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
_guideGroupLocked = guideGroupLocked; _guideGroupLocked = guideGroupLocked;
} }
foreach (int channelWatermarkId in Optional(state.ChannelWatermarkId)) foreach (int channelWatermarkId in state.ChannelWatermarkIds)
{ {
_channelWatermarkId = channelWatermarkId; _channelWatermarkIds.Add(channelWatermarkId);
} }
foreach (string preRollSequence in Optional(state.PreRollSequence)) foreach (string preRollSequence in Optional(state.PreRollSequence))
@ -199,7 +204,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
int? InstructionIndex, int? InstructionIndex,
int? GuideGroup, int? GuideGroup,
bool? GuideGroupLocked, bool? GuideGroupLocked,
int? ChannelWatermarkId, List<int> ChannelWatermarkIds,
string PreRollSequence); string PreRollSequence);
public record MidRollSequence(string Sequence, string Expression); public record MidRollSequence(string Sequence, string Expression);

6223
ErsatzTV.Infrastructure.MySql/Migrations/20250806191133_Add_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

51
ErsatzTV.Infrastructure.MySql/Migrations/20250806191133_Add_PlayoutItemWatermark.cs

@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlayoutItemWatermark",
columns: table => new
{
PlayoutItemId = table.Column<int>(type: "int", nullable: false),
WatermarkId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayoutItemWatermark", x => new { x.PlayoutItemId, x.WatermarkId });
table.ForeignKey(
name: "FK_PlayoutItemWatermark_ChannelWatermark_WatermarkId",
column: x => x.WatermarkId,
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlayoutItemWatermark_PlayoutItem_PlayoutItemId",
column: x => x.PlayoutItemId,
principalTable: "PlayoutItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PlayoutItemWatermark_WatermarkId",
table: "PlayoutItemWatermark",
column: "WatermarkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlayoutItemWatermark");
}
}
}

6223
ErsatzTV.Infrastructure.MySql/Migrations/20250806191615_Populate_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

23
ErsatzTV.Infrastructure.MySql/Migrations/20250806191615_Populate_PlayoutItemWatermark.cs

@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Populate_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"INSERT INTO `PlayoutItemWatermark` (`PlayoutItemId`, `WatermarkId`)
SELECT `Id`, `WatermarkId` FROM `PlayoutItem` WHERE `WatermarkId` IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6211
ErsatzTV.Infrastructure.MySql/Migrations/20250806195038_Remove_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

49
ErsatzTV.Infrastructure.MySql/Migrations/20250806195038_Remove_PlayoutItemWatermark.cs

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Remove_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PlayoutItem_ChannelWatermark_WatermarkId",
table: "PlayoutItem");
migrationBuilder.DropIndex(
name: "IX_PlayoutItem_WatermarkId",
table: "PlayoutItem");
migrationBuilder.DropColumn(
name: "WatermarkId",
table: "PlayoutItem");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "WatermarkId",
table: "PlayoutItem",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_PlayoutItem_WatermarkId",
table: "PlayoutItem",
column: "WatermarkId");
migrationBuilder.AddForeignKey(
name: "FK_PlayoutItem_ChannelWatermark_WatermarkId",
table: "PlayoutItem",
column: "WatermarkId",
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
}
}

50
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -1882,18 +1882,28 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("SubtitleMode") b.Property<int?>("SubtitleMode")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("WatermarkId")
.HasColumnType("int");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("MediaItemId"); b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId"); b.HasIndex("PlayoutId");
b.ToTable("PlayoutItem", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.Property<int>("PlayoutItemId")
.HasColumnType("int");
b.Property<int>("WatermarkId")
.HasColumnType("int");
b.HasKey("PlayoutItemId", "WatermarkId");
b.HasIndex("WatermarkId"); b.HasIndex("WatermarkId");
b.ToTable("PlayoutItem", (string)null); b.ToTable("PlayoutItemWatermark");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
@ -4617,14 +4627,26 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("MediaItem"); b.Navigation("MediaItem");
b.Navigation("Playout"); b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
.WithMany("PlayoutItemWatermarks")
.HasForeignKey("PlayoutItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany("PlayoutItemWatermarks")
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlayoutItem");
b.Navigation("Watermark"); b.Navigation("Watermark");
}); });
@ -5748,6 +5770,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Playouts"); b.Navigation("Playouts");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{ {
b.Navigation("CollectionItems"); b.Navigation("CollectionItems");
@ -5930,6 +5957,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Templates"); b.Navigation("Templates");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{ {
b.Navigation("Items"); b.Navigation("Items");

6060
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191226_Add_PlayoutItemWatermarkTable.Designer.cs generated

File diff suppressed because it is too large Load Diff

50
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191226_Add_PlayoutItemWatermarkTable.cs

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutItemWatermarkTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlayoutItemWatermark",
columns: table => new
{
PlayoutItemId = table.Column<int>(type: "INTEGER", nullable: false),
WatermarkId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayoutItemWatermark", x => new { x.PlayoutItemId, x.WatermarkId });
table.ForeignKey(
name: "FK_PlayoutItemWatermark_ChannelWatermark_WatermarkId",
column: x => x.WatermarkId,
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlayoutItemWatermark_PlayoutItem_PlayoutItemId",
column: x => x.PlayoutItemId,
principalTable: "PlayoutItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PlayoutItemWatermark_WatermarkId",
table: "PlayoutItemWatermark",
column: "WatermarkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlayoutItemWatermark");
}
}
}

6060
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191314_Populate_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

23
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806191314_Populate_PlayoutItemWatermark.cs

@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Populate_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"INSERT INTO `PlayoutItemWatermark` (`PlayoutItemId`, `WatermarkId`)
SELECT `Id`, `WatermarkId` FROM `PlayoutItem` WHERE `WatermarkId` IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6048
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806195120_Remove_PlayoutItemWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

49
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806195120_Remove_PlayoutItemWatermark.cs

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Remove_PlayoutItemWatermark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PlayoutItem_ChannelWatermark_WatermarkId",
table: "PlayoutItem");
migrationBuilder.DropIndex(
name: "IX_PlayoutItem_WatermarkId",
table: "PlayoutItem");
migrationBuilder.DropColumn(
name: "WatermarkId",
table: "PlayoutItem");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "WatermarkId",
table: "PlayoutItem",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_PlayoutItem_WatermarkId",
table: "PlayoutItem",
column: "WatermarkId");
migrationBuilder.AddForeignKey(
name: "FK_PlayoutItem_ChannelWatermark_WatermarkId",
table: "PlayoutItem",
column: "WatermarkId",
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
}
}

50
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -1793,18 +1793,28 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("SubtitleMode") b.Property<int?>("SubtitleMode")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("WatermarkId")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("MediaItemId"); b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId"); b.HasIndex("PlayoutId");
b.ToTable("PlayoutItem", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.Property<int>("PlayoutItemId")
.HasColumnType("INTEGER");
b.Property<int>("WatermarkId")
.HasColumnType("INTEGER");
b.HasKey("PlayoutItemId", "WatermarkId");
b.HasIndex("WatermarkId"); b.HasIndex("WatermarkId");
b.ToTable("PlayoutItem", (string)null); b.ToTable("PlayoutItemWatermark");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
@ -4454,14 +4464,26 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("MediaItem"); b.Navigation("MediaItem");
b.Navigation("Playout"); b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
.WithMany("PlayoutItemWatermarks")
.HasForeignKey("PlayoutItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany("PlayoutItemWatermarks")
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlayoutItem");
b.Navigation("Watermark"); b.Navigation("Watermark");
}); });
@ -5585,6 +5607,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Playouts"); b.Navigation("Playouts");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{ {
b.Navigation("CollectionItems"); b.Navigation("CollectionItems");
@ -5767,6 +5794,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Templates"); b.Navigation("Templates");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{ {
b.Navigation("Items"); b.Navigation("Items");

17
ErsatzTV.Infrastructure/Data/Configurations/PlayoutItemConfiguration.cs

@ -16,10 +16,17 @@ public class PlayoutItemConfiguration : IEntityTypeConfiguration<PlayoutItem>
.HasForeignKey(pi => pi.MediaItemId) .HasForeignKey(pi => pi.MediaItemId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Watermark) builder.HasMany(c => c.Watermarks)
.WithMany() .WithMany(m => m.PlayoutItems)
.HasForeignKey(i => i.WatermarkId) .UsingEntity<PlayoutItemWatermark>(
.OnDelete(DeleteBehavior.SetNull) j => j.HasOne(ci => ci.Watermark)
.IsRequired(false); .WithMany(mi => mi.PlayoutItemWatermarks)
.HasForeignKey(ci => ci.WatermarkId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(ci => ci.PlayoutItem)
.WithMany(c => c.PlayoutItemWatermarks)
.HasForeignKey(ci => ci.PlayoutItemId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(ci => new { ci.PlayoutItemId, ci.WatermarkId }));
} }
} }

2
ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs

@ -89,6 +89,8 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
} }
finally finally
{ {
await pipeWriter.CompleteAsync();
foreach (var element in elements.OfType<IDisposable>()) foreach (var element in elements.OfType<IDisposable>())
{ {
element.Dispose(); element.Dispose();

17
ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs

@ -43,7 +43,22 @@ public class WatermarkElement : IGraphicsElement, IDisposable
public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken) public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
{ {
if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression)) if (_watermark.Mode is ChannelWatermarkMode.Intermittent)
{
string expressionString = $@"
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < 1,
(time_of_day_seconds % {_watermark.FrequencyMinutes * 60}),
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds},
1,
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds + 1},
1 - ((time_of_day_seconds % {_watermark.FrequencyMinutes * 60} - {1 + _watermark.DurationSeconds}) / 1),
0
)
)
)";
_expression = new Expression(expressionString);
}
else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
{ {
_expression = new Expression(_watermark.OpacityExpression); _expression = new Expression(_watermark.OpacityExpression);
} }

4
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -367,7 +367,7 @@ public class TranscodingTests
now, now,
now + TimeSpan.FromSeconds(3), now + TimeSpan.FromSeconds(3),
now, now,
Option<ChannelWatermark>.None, [],
GetWatermark(watermark), GetWatermark(watermark),
"drm", "drm",
VaapiDriver.RadeonSI, VaapiDriver.RadeonSI,
@ -646,7 +646,7 @@ public class TranscodingTests
now, now,
now + TimeSpan.FromSeconds(3), now + TimeSpan.FromSeconds(3),
now, now,
Option<ChannelWatermark>.None, [],
channelWatermark, channelWatermark,
"drm", "drm",
VaapiDriver.RadeonSI, VaapiDriver.RadeonSI,

18
ErsatzTV/Pages/YamlValidator.razor

@ -97,12 +97,20 @@
if (_exists) if (_exists)
{ {
_yamlText = await File.ReadAllTextAsync(_yamlFile); _yamlText = await File.ReadAllTextAsync(_yamlFile);
_jsonText = YamlScheduleValidator.ToJson(_yamlText); try
var messages = await YamlScheduleValidator.GetValidationMessages(_yamlText, _isImport); {
_messagesCount = messages.Count; _jsonText = YamlScheduleValidator.ToJson(_yamlText);
_messages = string.Join("\n", messages); var messages = await YamlScheduleValidator.GetValidationMessages(_yamlText, _isImport);
_messagesCount = messages.Count;
_messages = string.Join("\n", messages);
StateHasChanged(); StateHasChanged();
}
catch (Exception e)
{
_messages = e.ToString();
_messagesCount = 1;
}
} }
} }

15
ErsatzTV/Resources/yaml-playout-import.schema.json

@ -180,7 +180,8 @@
"all": { "type": "null" }, "all": { "type": "null" },
"content": { "type": "string" }, "content": { "type": "string" },
"custom_title": { "type": "string" }, "custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" } "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "all", "content" ], "required": [ "all", "content" ],
"additionalProperties": false "additionalProperties": false
@ -191,7 +192,8 @@
"count": { "type": "integer" }, "count": { "type": "integer" },
"content": { "type": "string" }, "content": { "type": "string" },
"custom_title": { "type": "string" }, "custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" } "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "count", "content" ], "required": [ "count", "content" ],
"additionalProperties": false "additionalProperties": false
@ -207,7 +209,8 @@
"offline_tail": { "type": "boolean" }, "offline_tail": { "type": "boolean" },
"stop_before_end": { "type": "boolean" }, "stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }, "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" } "custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "duration", "content" ], "required": [ "duration", "content" ],
"additionalProperties": false "additionalProperties": false
@ -221,7 +224,8 @@
"fallback": { "type": "string" }, "fallback": { "type": "string" },
"discard_attempts": { "type": "integer" }, "discard_attempts": { "type": "integer" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }, "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" } "custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "pad_to_next", "content" ], "required": [ "pad_to_next", "content" ],
"additionalProperties": false "additionalProperties": false
@ -238,7 +242,8 @@
"discard_attempts": { "type": "integer" }, "discard_attempts": { "type": "integer" },
"stop_before_end": { "type": "boolean" }, "stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }, "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" } "custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "pad_until", "content" ], "required": [ "pad_until", "content" ],
"additionalProperties": false "additionalProperties": false

15
ErsatzTV/Resources/yaml-playout.schema.json

@ -229,7 +229,8 @@
"all": { "type": "null" }, "all": { "type": "null" },
"content": { "type": "string" }, "content": { "type": "string" },
"custom_title": { "type": "string" }, "custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" } "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "all", "content" ], "required": [ "all", "content" ],
"additionalProperties": false "additionalProperties": false
@ -240,7 +241,8 @@
"count": { "type": "integer" }, "count": { "type": "integer" },
"content": { "type": "string" }, "content": { "type": "string" },
"custom_title": { "type": "string" }, "custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" } "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "count", "content" ], "required": [ "count", "content" ],
"additionalProperties": false "additionalProperties": false
@ -256,7 +258,8 @@
"offline_tail": { "type": "boolean" }, "offline_tail": { "type": "boolean" },
"stop_before_end": { "type": "boolean" }, "stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }, "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" } "custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "duration", "content" ], "required": [ "duration", "content" ],
"additionalProperties": false "additionalProperties": false
@ -270,7 +273,8 @@
"fallback": { "type": "string" }, "fallback": { "type": "string" },
"discard_attempts": { "type": "integer" }, "discard_attempts": { "type": "integer" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }, "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" } "custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "pad_to_next", "content" ], "required": [ "pad_to_next", "content" ],
"additionalProperties": false "additionalProperties": false
@ -287,7 +291,8 @@
"discard_attempts": { "type": "integer" }, "discard_attempts": { "type": "integer" },
"stop_before_end": { "type": "boolean" }, "stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" }, "filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" } "custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
}, },
"required": [ "pad_until", "content" ], "required": [ "pad_until", "content" ],
"additionalProperties": false "additionalProperties": false

Loading…
Cancel
Save