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
This commit is contained in:
Jason Dove
2025-08-06 21:22:20 +00:00
committed by GitHub
parent a28f40e14b
commit 5a5c049835
41 changed files with 37436 additions and 206 deletions
+8 -3
View File
@@ -6,9 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add *experimental* graphics engine
- `Permanent` watermarks will use new graphics engine
- `Opacity Expression` watermarks will use new graphics engine
- `Intermittent` watermarks will still use normal overlay pipeline (for now)
- All watermarks will use new graphics engine
- Add `Opacity Expression` watermark mode
- This allows specifying an expression that returns an opacity between 0.0 and 1.0
- 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
- `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
### Added
- Add `Troubleshoot Playback` to overflow menu on all media cards
@@ -166,6 +166,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.Include(p => p.Channel)
.Include(p => p.Deco)
.Include(p => p.Items)
.ThenInclude(pi => pi.Watermarks)
.Include(p => p.PlayoutHistory)
.Include(p => p.Templates)
.ThenInclude(t => t.Template)
@@ -181,6 +182,10 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Watermark)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
@@ -212,6 +217,9 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.ThenInclude(a => a.MediaItem)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Watermark)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
@@ -467,6 +467,7 @@ public class HlsSessionWorker : IHlsSessionWorker
foreach (var graphicsEngineContext in processModel.GraphicsEngineContext)
{
var pipe = new Pipe();
maybePipe = pipe;
processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(pipe.Reader.AsStream()));
// fire and forget graphics engine task
@@ -164,7 +164,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(i => i.Watermark)
.Include(i => i.Watermarks)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
@@ -173,7 +173,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Include(i => i.Watermark)
.Include(i => i.Watermarks)
.ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(item => ValidatePlayoutItemPath(dbContext, item));
@@ -242,7 +242,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.BindT(watermarkId => dbContext.ChannelWatermarks
.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;
WatermarkResult watermarkResult = GetPlayoutItemWatermark(playoutItemWithPath.PlayoutItem, now);
switch (watermarkResult)
@@ -254,7 +256,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
disableWatermarks = true;
break;
case CustomWatermark watermark:
playoutItemWatermark = watermark.Watermark;
playoutItemWatermarks.Clear();
playoutItemWatermarks.Add(watermark.Watermark);
break;
}
@@ -263,7 +266,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
playoutItemWatermark,
playoutItemWatermarks.HeadOrNone(),
maybeGlobalWatermark,
ffmpegPath,
ffprobePath,
@@ -278,7 +281,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png";
disableWatermarks = false;
playoutItemWatermark = new ChannelWatermark
playoutItemWatermarks.Clear();
playoutItemWatermarks.Add(new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
Size = WatermarkSize.Scaled,
@@ -289,7 +293,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Location = WatermarkLocation.TopLeft,
ImageSource = ChannelWatermarkImageSource.Resource,
Image = image
};
});
}
}
@@ -335,7 +339,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
start,
finish,
effectiveNow,
playoutItemWatermark,
playoutItemWatermarks,
maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
@@ -78,11 +78,11 @@ public class PrepareTroubleshootingPlaybackHandler(
return BaseError.New("Media item does not exist on disk");
}
Option<ChannelWatermark> maybeWatermark = Option<ChannelWatermark>.None;
List<ChannelWatermark> watermarks = [];
if (request.WatermarkId > 0)
{
maybeWatermark = await dbContext.ChannelWatermarks
.SelectOneAsync(cw => cw.Id, cw => cw.Id == request.WatermarkId);
watermarks.AddRange(await dbContext.ChannelWatermarks
.SelectOneAsync(cw => cw.Id, cw => cw.Id == request.WatermarkId));
}
DateTimeOffset now = DateTimeOffset.Now;
@@ -132,7 +132,7 @@ public class PrepareTroubleshootingPlaybackHandler(
now,
now + duration,
now,
maybeWatermark,
watermarks,
Option<ChannelWatermark>.None,
ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver,
+2
View File
@@ -20,6 +20,8 @@ public class ChannelWatermark
public int Opacity { get; set; }
public bool PlaceWithinSourceContent { get; set; }
public string OpacityExpression { get; set; }
public List<PlayoutItem> PlayoutItems { get; set; }
public List<PlayoutItemWatermark> PlayoutItemWatermarks { get; set; }
}
public enum ChannelWatermarkMode
+3 -4
View File
@@ -22,8 +22,7 @@ public class PlayoutItem
public TimeSpan InPoint { get; set; }
public TimeSpan OutPoint { get; set; }
public string ChapterTitle { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
public List<ChannelWatermark> Watermarks { get; set; }
public bool DisableWatermarks { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; }
@@ -32,6 +31,7 @@ public class PlayoutItem
public string BlockKey { get; set; }
public string CollectionKey { get; set; }
public string CollectionEtag { get; set; }
public List<PlayoutItemWatermark> PlayoutItemWatermarks { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
@@ -55,8 +55,7 @@ public class PlayoutItem
InPoint = chapter.StartTime,
OutPoint = chapter.EndTime,
ChapterTitle = chapter.Title,
Watermark = Watermark,
WatermarkId = WatermarkId,
Watermarks = Watermarks,
DisableWatermarks = DisableWatermarks,
PreferredAudioLanguageCode = PreferredAudioLanguageCode,
PreferredAudioTitle = PreferredAudioTitle,
@@ -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; }
}
@@ -62,7 +62,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
Option<ChannelWatermark> playoutItemWatermark,
List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark,
string vaapiDisplay,
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
{
FFmpegProfileAudioFormat.Aac => AudioFormat.Aac,
@@ -345,31 +321,67 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
method);
}).Flatten();
Option<WatermarkInputFile> watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None;
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
// use graphics engine for permanent watermarks, or opacity expressions
var maybeGraphicsEngineWatermark = watermarkOptions
.Where(o => o.Watermark
.Map(wm => wm.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.OpacityExpression)
.IfNone(false));
foreach (var options in maybeGraphicsEngineWatermark)
// use graphics engine for all watermarks
if (!disableWatermarks)
{
watermarkInputFile = Option<WatermarkInputFile>.None;
var watermarks = new Dictionary<int, WatermarkElementContext>();
graphicsEngineInput = new GraphicsEngineInput();
// still need channel and global watermarks
if (playoutItemWatermarks.Count == 0)
{
WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
Option<ChannelWatermark>.None,
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(
[watermark],
channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24),
ChannelStartTime: channelStartTime,
ContentStartTime: start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now);
// load all playout item watermarks
foreach (var playoutItemWatermark in playoutItemWatermarks)
{
WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
playoutItemWatermark,
globalWatermark,
videoVersion,
None,
None);
foreach (var watermark in options.Watermark)
{
// don't allow duplicates
watermarks.TryAdd(watermark.Id, new WatermarkElementContext(options));
}
}
// only use graphics engine when we have watermarks
if (watermarks.Count > 0)
{
graphicsEngineInput = new GraphicsEngineInput();
graphicsEngineContext = new GraphicsEngineContext(
watermarks.Values.OfType<GraphicsElementContext>().ToList(),
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);
@@ -1014,71 +1026,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
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(
string ffmpegPath,
Option<VideoInputFile> videoInputFile,
@@ -26,7 +26,7 @@ public interface IFFmpegProcessService
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
Option<ChannelWatermark> playoutItemWatermark,
List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark,
string vaapiDisplay,
VaapiDriver vaapiDriver,
+7 -4
View File
@@ -426,10 +426,13 @@ public class PlayoutBuilder : IPlayoutBuilder
// so only log the warning, and leave the bad data to fail tests
// playout.Items.Remove(futureItem);
_logger.LogInformation(
"{Count} playout items are scheduled after hard stop of {HardStop}; this is expected if duration is used.",
futureItemCount,
trimAfter);
if (futureItemCount > 0)
{
_logger.LogInformation(
"{Count} playout items are scheduled after hard stop of {HardStop}; this is expected if duration is used.",
futureItemCount,
trimAfter);
}
}
return playout;
@@ -157,13 +157,18 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
};
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc);
@@ -76,13 +76,18 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
};
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
var enumeratorStates = new Dictionary<CollectionKey, CollectionEnumeratorState>();
foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{
@@ -100,13 +100,18 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
};
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
playoutItems.AddRange(
@@ -47,13 +47,18 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
};
if (scheduleItem.WatermarkId is not null)
{
playoutItem.Watermarks ??= [];
playoutItem.Watermarks.Add(scheduleItem.Watermark);
}
List<PlayoutItem> playoutItems = AddFiller(
playoutBuilderState,
collectionEnumerators,
@@ -58,17 +58,23 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode
GuideGroup = context.PeekNextGuideGroup()
GuideGroup = context.PeekNextGuideGroup(),
//GuideStart = effectiveBlock.Start.UtcDateTime,
//GuideFinish = blockFinish.UtcDateTime,
//BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
//CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
//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);
@@ -83,17 +83,23 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode
GuideGroup = context.PeekNextGuideGroup()
GuideGroup = context.PeekNextGuideGroup(),
//GuideStart = effectiveBlock.Start.UtcDateTime,
//GuideFinish = blockFinish.UtcDateTime,
//BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
//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);
@@ -122,12 +122,18 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
GuideGroup = context.PeekNextGuideGroup(),
FillerKind = fillerKind,
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)
@@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) : IYamlPlayoutHandler
{
private readonly Dictionary<string, Option<ChannelWatermark>> _watermarkCache = new();
public bool Reset => false;
public async Task<bool> Handle(
@@ -24,17 +26,48 @@ public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) :
if (watermark.Watermark && !string.IsNullOrWhiteSpace(watermark.Name))
{
Option<ChannelWatermark> maybeWatermark = await channelRepository.GetWatermarkByName(watermark.Name);
foreach (ChannelWatermark channelWatermark in maybeWatermark)
foreach (var wm in await GetChannelWatermarkByName(watermark.Name))
{
context.SetChannelWatermarkId(channelWatermark.Id);
context.SetChannelWatermarkId(wm.Id);
}
}
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;
}
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;
}
}
@@ -14,10 +14,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
private readonly System.Collections.Generic.HashSet<int> _visitedInstructions = [];
private readonly Stack<FillerKind> _fillerKind = new();
private readonly System.Collections.Generic.HashSet<int> _channelWatermarkIds = [];
private int _guideGroup = guideGroup;
private bool _guideGroupLocked;
private int _instructionIndex;
private Option<int> _channelWatermarkId;
private Option<string> _preRollSequence;
private Option<string> _postRollSequence;
private Option<MidRollSequence> _midRollSequence;
@@ -84,15 +84,20 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
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)
{
@@ -142,19 +147,19 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
public string Serialize()
{
int? channelWatermarkId = null;
foreach (int id in _channelWatermarkId)
{
channelWatermarkId = id;
}
string preRollSequence = null;
foreach (string sequence in _preRollSequence)
{
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);
}
@@ -184,9 +189,9 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
_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))
@@ -199,7 +204,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
int? InstructionIndex,
int? GuideGroup,
bool? GuideGroupLocked,
int? ChannelWatermarkId,
List<int> ChannelWatermarkIds,
string PreRollSequence);
public record MidRollSequence(string Sequence, string Expression);
File diff suppressed because it is too large Load Diff
@@ -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");
}
}
}
@@ -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)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
}
@@ -1882,18 +1882,28 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("SubtitleMode")
.HasColumnType("int");
b.Property<int?>("WatermarkId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MediaItemId");
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.ToTable("PlayoutItem", (string)null);
b.ToTable("PlayoutItemWatermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
@@ -4617,14 +4627,26 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("MediaItem");
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");
});
@@ -5748,6 +5770,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{
b.Navigation("CollectionItems");
@@ -5930,6 +5957,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Templates");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Navigation("Items");
@@ -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");
}
}
}
@@ -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)
{
}
}
}
@@ -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);
}
}
}
@@ -1793,18 +1793,28 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("SubtitleMode")
.HasColumnType("INTEGER");
b.Property<int?>("WatermarkId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaItemId");
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.ToTable("PlayoutItem", (string)null);
b.ToTable("PlayoutItemWatermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
@@ -4454,14 +4464,26 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("MediaItem");
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");
});
@@ -5585,6 +5607,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{
b.Navigation("CollectionItems");
@@ -5767,6 +5794,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Templates");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemWatermarks");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Navigation("Items");
@@ -16,10 +16,17 @@ public class PlayoutItemConfiguration : IEntityTypeConfiguration<PlayoutItem>
.HasForeignKey(pi => pi.MediaItemId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Watermark)
.WithMany()
.HasForeignKey(i => i.WatermarkId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasMany(c => c.Watermarks)
.WithMany(m => m.PlayoutItems)
.UsingEntity<PlayoutItemWatermark>(
j => j.HasOne(ci => ci.Watermark)
.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 }));
}
}
@@ -89,6 +89,8 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
}
finally
{
await pipeWriter.CompleteAsync();
foreach (var element in elements.OfType<IDisposable>())
{
element.Dispose();
@@ -43,7 +43,22 @@ public class WatermarkElement : IGraphicsElement, IDisposable
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);
}
@@ -367,7 +367,7 @@ public class TranscodingTests
now,
now + TimeSpan.FromSeconds(3),
now,
Option<ChannelWatermark>.None,
[],
GetWatermark(watermark),
"drm",
VaapiDriver.RadeonSI,
@@ -646,7 +646,7 @@ public class TranscodingTests
now,
now + TimeSpan.FromSeconds(3),
now,
Option<ChannelWatermark>.None,
[],
channelWatermark,
"drm",
VaapiDriver.RadeonSI,
+13 -5
View File
@@ -97,12 +97,20 @@
if (_exists)
{
_yamlText = await File.ReadAllTextAsync(_yamlFile);
_jsonText = YamlScheduleValidator.ToJson(_yamlText);
var messages = await YamlScheduleValidator.GetValidationMessages(_yamlText, _isImport);
_messagesCount = messages.Count;
_messages = string.Join("\n", messages);
try
{
_jsonText = YamlScheduleValidator.ToJson(_yamlText);
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;
}
}
}
@@ -180,7 +180,8 @@
"all": { "type": "null" },
"content": { "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" ],
"additionalProperties": false
@@ -191,7 +192,8 @@
"count": { "type": "integer" },
"content": { "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" ],
"additionalProperties": false
@@ -207,7 +209,8 @@
"offline_tail": { "type": "boolean" },
"stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "duration", "content" ],
"additionalProperties": false
@@ -221,7 +224,8 @@
"fallback": { "type": "string" },
"discard_attempts": { "type": "integer" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "pad_to_next", "content" ],
"additionalProperties": false
@@ -238,7 +242,8 @@
"discard_attempts": { "type": "integer" },
"stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "pad_until", "content" ],
"additionalProperties": false
+10 -5
View File
@@ -229,7 +229,8 @@
"all": { "type": "null" },
"content": { "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" ],
"additionalProperties": false
@@ -240,7 +241,8 @@
"count": { "type": "integer" },
"content": { "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" ],
"additionalProperties": false
@@ -256,7 +258,8 @@
"offline_tail": { "type": "boolean" },
"stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "duration", "content" ],
"additionalProperties": false
@@ -270,7 +273,8 @@
"fallback": { "type": "string" },
"discard_attempts": { "type": "integer" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "pad_to_next", "content" ],
"additionalProperties": false
@@ -287,7 +291,8 @@
"discard_attempts": { "type": "integer" },
"stop_before_end": { "type": "boolean" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },
"custom_title": { "type": "string" }
"custom_title": { "type": "string" },
"disable_watermarks": { "type": "boolean" }
},
"required": [ "pad_until", "content" ],
"additionalProperties": false