diff --git a/CHANGELOG.md b/CHANGELOG.md index e21c410ea..ed8fe55ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added - Add `Streaming Engine` dropdown to playback troubleshooter to support troubleshooting Next engine playback -- Next engine: use `overlay_qsv` for hardware-accelerated watermarks and image subtitles +- Next engine + - Use `overlay_qsv` for hardware-accelerated watermarks and image subtitles + - Support multiple watermarks (still only `permanent` and `intermittent` modes, not `opacity expression`) ### Changed - Upgrade Mesa driver in docker from 25.2.8 to 26.0.3 to fix issues with hevc_vaapi encoder when using radeonsi driver diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs index 59f5e7661..7d498bddb 100644 --- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs @@ -245,7 +245,7 @@ public partial class SyncNextPlayoutHandler( targetFolder, $"{first.StartOffset.ToUnixTimeMilliseconds()}_{last.FinishOffset.ToUnixTimeMilliseconds()}.json"); - var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.2", Items = [] }; + var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.3", Items = [] }; foreach (PlayoutItem playoutItem in group) { Option maybeNextPlayoutItem = await playoutItemConverter.ToNext( diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 2d44a7c21..5005516db 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -335,7 +335,7 @@ public class PrepareTroubleshootingPlaybackHandler( InPoint = inPoint, OutPoint = outPoint, ChapterTitle = null, - Watermarks = [], + Watermarks = [.. watermarks.Map(wm => wm.Watermark)], DisableWatermarks = request.WatermarkIds.Count == 0, PreferredAudioLanguageCode = null, PreferredAudioTitle = null, @@ -352,7 +352,7 @@ public class PrepareTroubleshootingPlaybackHandler( Option maybeNextPlayoutItem = await playoutItemConverter.ToNext( Some(channel), - watermarks.HeadOrNone().Map(wm => wm.Watermark), + [], TimeSpan.Zero, playoutItem, await GetSubtitles(mediaItem, request), @@ -363,7 +363,7 @@ public class PrepareTroubleshootingPlaybackHandler( { var playout = new Core.Next.Playout { - Version = "https://ersatztv.org/playout/version/0.0.2", + Version = "https://ersatztv.org/playout/version/0.0.3", Items = [nextPlayoutItem] }; diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs index 537c77372..492fd09a8 100644 --- a/ErsatzTV.Core/Next/Playout.cs +++ b/ErsatzTV.Core/Next/Playout.cs @@ -64,6 +64,14 @@ namespace ErsatzTV.Core.Next [JsonPropertyName("finish")] public DateTimeOffset Finish { get; set; } + /// + /// Ordered media-backed graphics layers. The first entry is lowest and the last entry is + /// highest; a compatibility `watermark` is always below this array. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("graphics")] + public List? Graphics { get; set; } + /// /// Stable identifier for this item, unique within the playout. /// @@ -91,14 +99,93 @@ namespace ErsatzTV.Core.Next public PlayoutItemTracks? Tracks { get; set; } /// - /// Watermark (image/video overlay) to composite on top of the primary content for the - /// duration of this item. Omit for no watermark. + /// Compatibility graphics layer composited below every entry in `graphics`. Omit for no + /// legacy watermark. /// [JsonPropertyName("watermark")] - public Watermark? Watermark { get; set; } + public GraphicsLayer? Watermark { get; set; } } /// + /// An image or video graphics layer composited on top of the primary content. Sized and + /// positioned relative to the primary content's frame. + /// + public partial class GraphicsLayer + { + /// + /// Horizontal offset from the anchor `location`, as a percent of primary content width + /// (0–100). Omit for 0. + /// + [JsonPropertyName("horizontal_margin_percent")] + [JsonConverter(typeof(MinMaxValueCheckConverter))] + public double? HorizontalMarginPercent { get; set; } + + /// + /// Anchor position within the primary content frame. + /// + [JsonPropertyName("location")] + public GraphicsLocation Location { get; set; } + + /// + /// Opacity as a percent (0–100). Omit for fully opaque (100). + /// + [JsonPropertyName("opacity_percent")] + [JsonConverter(typeof(MinMaxValueCheckConverter))] + public double? OpacityPercent { get; set; } + + /// + /// The source providing the graphics media (typically an image, but any `PlayoutItemSource` + /// is accepted). + /// + [JsonPropertyName("source")] + public PlayoutItemSource Source { get; set; } + + /// + /// Zero-based stream index within the source. If omitted, the server picks the first video + /// stream. + /// + [JsonPropertyName("stream_index")] + public long? StreamIndex { get; set; } + + /// + /// Visibility schedule for the graphics layer. Omit for an always-on layer. + /// + [JsonPropertyName("timing")] + public Timing? Timing { get; set; } + + /// + /// Vertical offset from the anchor `location`, as a percent of primary content height + /// (0–100). Omit for 0. + /// + [JsonPropertyName("vertical_margin_percent")] + [JsonConverter(typeof(MinMaxValueCheckConverter))] + public double? VerticalMarginPercent { get; set; } + + /// + /// Scale the graphics layer to this percent of the primary content width (0–100). Omit to + /// use its actual size. + /// + [JsonPropertyName("width_percent")] + [JsonConverter(typeof(MinMaxValueCheckConverter))] + public double? WidthPercent { get; set; } + + /// + /// When true, position margins are measured from the edges of the source content rather than + /// the padded output frame, so letterbox/pillarbox bars push the graphics layer inward and + /// keep it inside the visible content. When false, margins are relative to the full padded + /// frame, so a 0% margin can land inside the bars. Has no effect when the primary content + /// fills the output (crop/stretch). Omit for false. + /// + [JsonPropertyName("within_source_content")] + public bool? WithinSourceContent { get; set; } + } + + /// + /// The source providing the graphics media (typically an image, but any `PlayoutItemSource` + /// is accepted). + /// + /// A media source. Exactly one variant, distinguished by `source_type`. + /// /// A file on the local filesystem reachable by the server. /// /// A synthetic source produced by an ffmpeg lavfi filter graph. @@ -131,7 +218,7 @@ namespace ErsatzTV.Core.Next /// - `x-etv-until` — the placeholder's `finish`; the resolver should not return an item that /// extends beyond this (it will be clamped if it does). /// - public partial class Source + public partial class PlayoutItemSource { /// /// Optional start offset into the source, in milliseconds. @@ -454,135 +541,61 @@ namespace ErsatzTV.Core.Next } /// - /// Per-track overrides for a playout item. Omit a field to use the server default for that - /// track kind (first stream of that kind in the item's `source`, if any). - /// - public partial class PlayoutItemTracks - { - /// - /// Audio track selection. - /// - [JsonPropertyName("audio")] - public TrackSelection? Audio { get; set; } - - /// - /// Subtitle track selection. - /// - [JsonPropertyName("subtitle")] - public TrackSelection? Subtitle { get; set; } - - /// - /// Video track selection. - /// - [JsonPropertyName("video")] - public TrackSelection? Video { get; set; } - } - - /// - /// Selects a single track (video or audio). + /// Controls when the graphics layer is shown. Exactly one variant, distinguished by + /// `timing_type`. /// - /// - `source` absent: inherit the parent `PlayoutItem.source`. - /// - `source` present: use this source for this track, overriding the parent. - /// - `stream_index` absent: server picks the first stream of the track's kind in the - /// effective source. - /// - `stream_index` present: use this specific stream within the effective source. - /// - public partial class TrackSelection - { - /// - /// Source to pull this track from. If omitted, inherits from the parent `PlayoutItem.source`. - /// - [JsonPropertyName("source")] - public Source? Source { get; set; } - - /// - /// Zero-based stream index within the effective source. If omitted, the server picks the - /// first stream of this track's kind. - /// - [JsonPropertyName("stream_index")] - public long? StreamIndex { get; set; } - } - - /// - /// An image or video overlay composited on top of the primary content. Sized and positioned - /// relative to the primary content's frame. + /// Cyclical visibility: the graphics layer fades in, holds, and fades out once every + /// `frequency_ms`. Cycle length is start-to-start, so `2 * fade_ms + hold_ms` must be ≤ + /// `frequency_ms`, and `fade_ms` must be ≤ `hold_ms`. /// - public partial class Watermark + public partial class Timing { /// - /// Horizontal offset from the anchor `location`, as a percent of primary content width - /// (0–100). Omit for 0. - /// - [JsonPropertyName("horizontal_margin_percent")] - [JsonConverter(typeof(MinMaxValueCheckConverter))] - public double? HorizontalMarginPercent { get; set; } - - /// - /// Anchor position within the primary content frame. - /// - [JsonPropertyName("location")] - public WatermarkLocation Location { get; set; } - - /// - /// Opacity as a percent (0–100). Omit for fully opaque (100). + /// Reference clock used to position cycles. /// - [JsonPropertyName("opacity_percent")] - [JsonConverter(typeof(MinMaxValueCheckConverter))] - public double? OpacityPercent { get; set; } + [JsonPropertyName("clock")] + public PeriodicClock Clock { get; set; } /// - /// The source providing the watermark media (typically an image, but any `PlayoutItemSource` - /// is accepted). + /// Cap on the time, measured from the start of the playout item in milliseconds, during + /// which new appearances are allowed to begin. An appearance already in progress at the cap + /// is allowed to fade out cleanly. Omit for no cap. /// - [JsonPropertyName("source")] - public PlayoutItemSource Source { get; set; } + [JsonPropertyName("disable_after_ms")] + public long? DisableAfterMs { get; set; } /// - /// Zero-based stream index within the source. If omitted, the server picks the first video - /// stream. + /// Fade-in and fade-out duration in milliseconds (symmetric). Must be ≤ `hold_ms`. Omit for + /// 1000; set to 0 for hard cuts. /// - [JsonPropertyName("stream_index")] - public long? StreamIndex { get; set; } + [JsonPropertyName("fade_ms")] + public long? FadeMs { get; set; } /// - /// Visibility schedule for the watermark. Omit for an always-on watermark. + /// Period of the cycle, start-to-start, in milliseconds. /// - [JsonPropertyName("timing")] - public Timing? Timing { get; set; } + [JsonPropertyName("frequency_ms")] + public long FrequencyMs { get; set; } /// - /// Vertical offset from the anchor `location`, as a percent of primary content height - /// (0–100). Omit for 0. + /// Time held fully visible between fade-in and fade-out, in milliseconds. /// - [JsonPropertyName("vertical_margin_percent")] - [JsonConverter(typeof(MinMaxValueCheckConverter))] - public double? VerticalMarginPercent { get; set; } + [JsonPropertyName("hold_ms")] + public long HoldMs { get; set; } /// - /// Scale the watermark to this percent of the primary content width (0–100). Omit to use the - /// watermark's actual size. + /// Shift the cycle by this many milliseconds. With `clock: wall` and `frequency_ms: 300000`, + /// an offset of 0 puts an appearance start at every wall-clock 5-minute boundary; 120000 + /// shifts to :02, :07, :12, etc. Omit for 0. /// - [JsonPropertyName("width_percent")] - [JsonConverter(typeof(MinMaxValueCheckConverter))] - public double? WidthPercent { get; set; } + [JsonPropertyName("phase_offset_ms")] + public long? PhaseOffsetMs { get; set; } - /// - /// When true, position margins are measured from the edges of the source content rather than - /// the padded output frame, so letterbox/pillarbox bars push the watermark inward and keep - /// it inside the visible content. When false, margins are relative to the full padded frame, - /// so a 0% margin can land inside the bars. Has no effect when the primary content fills the - /// output (crop/stretch). Omit for false. - /// - [JsonPropertyName("within_source_content")] - public bool? WithinSourceContent { get; set; } + [JsonPropertyName("timing_type")] + public TimingType TimingType { get; set; } } /// - /// A media source. Exactly one variant, distinguished by `source_type`. - /// - /// The source providing the watermark media (typically an image, but any `PlayoutItemSource` - /// is accepted). - /// /// A file on the local filesystem reachable by the server. /// /// A synthetic source produced by an ffmpeg lavfi filter graph. @@ -615,7 +628,7 @@ namespace ErsatzTV.Core.Next /// - `x-etv-until` — the placeholder's `finish`; the resolver should not return an item that /// extends beyond this (it will be clamped if it does). /// - public partial class PlayoutItemSource + public partial class Source { /// /// Optional start offset into the source, in milliseconds. @@ -737,69 +750,64 @@ namespace ErsatzTV.Core.Next } /// - /// Controls when the watermark is shown. Exactly one variant, distinguished by - /// `timing_type`. - /// - /// Cyclical visibility: the watermark fades in, holds, and fades out once every - /// `frequency_ms`. Cycle length is start-to-start, so `2 * fade_ms + hold_ms` must be ≤ - /// `frequency_ms`, and `fade_ms` must be ≤ `hold_ms`. + /// Per-track overrides for a playout item. Omit a field to use the server default for that + /// track kind (first stream of that kind in the item's `source`, if any). /// - public partial class Timing + public partial class PlayoutItemTracks { /// - /// Reference clock used to position cycles. - /// - [JsonPropertyName("clock")] - public PeriodicClock Clock { get; set; } - - /// - /// Cap on the time, measured from the start of the playout item in milliseconds, during - /// which new appearances are allowed to begin. An appearance already in progress at the cap - /// is allowed to fade out cleanly. Omit for no cap. + /// Audio track selection. /// - [JsonPropertyName("disable_after_ms")] - public long? DisableAfterMs { get; set; } + [JsonPropertyName("audio")] + public TrackSelection? Audio { get; set; } /// - /// Fade-in and fade-out duration in milliseconds (symmetric). Must be ≤ `hold_ms`. Omit for - /// 1000; set to 0 for hard cuts. + /// Subtitle track selection. /// - [JsonPropertyName("fade_ms")] - public long? FadeMs { get; set; } + [JsonPropertyName("subtitle")] + public TrackSelection? Subtitle { get; set; } /// - /// Period of the cycle, start-to-start, in milliseconds. + /// Video track selection. /// - [JsonPropertyName("frequency_ms")] - public long FrequencyMs { get; set; } + [JsonPropertyName("video")] + public TrackSelection? Video { get; set; } + } + /// + /// Selects a single track (video or audio). + /// + /// - `source` absent: inherit the parent `PlayoutItem.source`. + /// - `source` present: use this source for this track, overriding the parent. + /// - `stream_index` absent: server picks the first stream of the track's kind in the + /// effective source. + /// - `stream_index` present: use this specific stream within the effective source. + /// + public partial class TrackSelection + { /// - /// Time held fully visible between fade-in and fade-out, in milliseconds. + /// Source to pull this track from. If omitted, inherits from the parent `PlayoutItem.source`. /// - [JsonPropertyName("hold_ms")] - public long HoldMs { get; set; } + [JsonPropertyName("source")] + public Source? Source { get; set; } /// - /// Shift the cycle by this many milliseconds. With `clock: wall` and `frequency_ms: 300000`, - /// an offset of 0 puts an appearance start at every wall-clock 5-minute boundary; 120000 - /// shifts to :02, :07, :12, etc. Omit for 0. + /// Zero-based stream index within the effective source. If omitted, the server picks the + /// first stream of this track's kind. /// - [JsonPropertyName("phase_offset_ms")] - public long? PhaseOffsetMs { get; set; } - - [JsonPropertyName("timing_type")] - public TimingType TimingType { get; set; } + [JsonPropertyName("stream_index")] + public long? StreamIndex { get; set; } } - public enum SourceType { Dynamic, Http, Lavfi, Local, Rtsp, Script }; - /// /// Anchor position within the primary content frame. /// /// Nine-position anchor within the primary content frame. Read like a 3×3 grid: rows /// top/center/bottom, columns left/center/right; the dead center is `center`. /// - public enum WatermarkLocation { BottomCenter, BottomLeft, BottomRight, Center, CenterLeft, CenterRight, TopCenter, TopLeft, TopRight }; + public enum GraphicsLocation { BottomCenter, BottomLeft, BottomRight, Center, CenterLeft, CenterRight, TopCenter, TopLeft, TopRight }; + + public enum SourceType { Dynamic, Http, Lavfi, Local, Rtsp, Script }; /// /// Reference clock used to position cycles. @@ -829,8 +837,8 @@ namespace ErsatzTV.Core.Next DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { + GraphicsLocationConverter.Singleton, SourceTypeConverter.Singleton, - WatermarkLocationConverter.Singleton, PeriodicClockConverter.Singleton, TimingTypeConverter.Singleton, new DateOnlyConverter(), @@ -840,60 +848,6 @@ namespace ErsatzTV.Core.Next }; } - internal class SourceTypeConverter : JsonConverter - { - public override bool CanConvert(Type t) => t == typeof(SourceType); - - public override SourceType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - switch (value) - { - case "dynamic": - return SourceType.Dynamic; - case "http": - return SourceType.Http; - case "lavfi": - return SourceType.Lavfi; - case "local": - return SourceType.Local; - case "rtsp": - return SourceType.Rtsp; - case "script": - return SourceType.Script; - } - throw new Exception("Cannot unmarshal type SourceType"); - } - - public override void Write(Utf8JsonWriter writer, SourceType value, JsonSerializerOptions options) - { - switch (value) - { - case SourceType.Dynamic: - JsonSerializer.Serialize(writer, "dynamic", options); - return; - case SourceType.Http: - JsonSerializer.Serialize(writer, "http", options); - return; - case SourceType.Lavfi: - JsonSerializer.Serialize(writer, "lavfi", options); - return; - case SourceType.Local: - JsonSerializer.Serialize(writer, "local", options); - return; - case SourceType.Rtsp: - JsonSerializer.Serialize(writer, "rtsp", options); - return; - case SourceType.Script: - JsonSerializer.Serialize(writer, "script", options); - return; - } - throw new Exception("Cannot marshal type SourceType"); - } - - public static readonly SourceTypeConverter Singleton = new SourceTypeConverter(); - } - internal class MinMaxValueCheckConverter : JsonConverter { public override bool CanConvert(Type t) => t == typeof(double); @@ -921,73 +875,127 @@ namespace ErsatzTV.Core.Next public static readonly MinMaxValueCheckConverter Singleton = new MinMaxValueCheckConverter(); } - internal class WatermarkLocationConverter : JsonConverter + internal class GraphicsLocationConverter : JsonConverter { - public override bool CanConvert(Type t) => t == typeof(WatermarkLocation); + public override bool CanConvert(Type t) => t == typeof(GraphicsLocation); - public override WatermarkLocation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override GraphicsLocation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var value = reader.GetString(); switch (value) { case "bottom_center": - return WatermarkLocation.BottomCenter; + return GraphicsLocation.BottomCenter; case "bottom_left": - return WatermarkLocation.BottomLeft; + return GraphicsLocation.BottomLeft; case "bottom_right": - return WatermarkLocation.BottomRight; + return GraphicsLocation.BottomRight; case "center": - return WatermarkLocation.Center; + return GraphicsLocation.Center; case "center_left": - return WatermarkLocation.CenterLeft; + return GraphicsLocation.CenterLeft; case "center_right": - return WatermarkLocation.CenterRight; + return GraphicsLocation.CenterRight; case "top_center": - return WatermarkLocation.TopCenter; + return GraphicsLocation.TopCenter; case "top_left": - return WatermarkLocation.TopLeft; + return GraphicsLocation.TopLeft; case "top_right": - return WatermarkLocation.TopRight; + return GraphicsLocation.TopRight; } - throw new Exception("Cannot unmarshal type WatermarkLocation"); + throw new Exception("Cannot unmarshal type GraphicsLocation"); } - public override void Write(Utf8JsonWriter writer, WatermarkLocation value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, GraphicsLocation value, JsonSerializerOptions options) { switch (value) { - case WatermarkLocation.BottomCenter: + case GraphicsLocation.BottomCenter: JsonSerializer.Serialize(writer, "bottom_center", options); return; - case WatermarkLocation.BottomLeft: + case GraphicsLocation.BottomLeft: JsonSerializer.Serialize(writer, "bottom_left", options); return; - case WatermarkLocation.BottomRight: + case GraphicsLocation.BottomRight: JsonSerializer.Serialize(writer, "bottom_right", options); return; - case WatermarkLocation.Center: + case GraphicsLocation.Center: JsonSerializer.Serialize(writer, "center", options); return; - case WatermarkLocation.CenterLeft: + case GraphicsLocation.CenterLeft: JsonSerializer.Serialize(writer, "center_left", options); return; - case WatermarkLocation.CenterRight: + case GraphicsLocation.CenterRight: JsonSerializer.Serialize(writer, "center_right", options); return; - case WatermarkLocation.TopCenter: + case GraphicsLocation.TopCenter: JsonSerializer.Serialize(writer, "top_center", options); return; - case WatermarkLocation.TopLeft: + case GraphicsLocation.TopLeft: JsonSerializer.Serialize(writer, "top_left", options); return; - case WatermarkLocation.TopRight: + case GraphicsLocation.TopRight: JsonSerializer.Serialize(writer, "top_right", options); return; } - throw new Exception("Cannot marshal type WatermarkLocation"); + throw new Exception("Cannot marshal type GraphicsLocation"); } - public static readonly WatermarkLocationConverter Singleton = new WatermarkLocationConverter(); + public static readonly GraphicsLocationConverter Singleton = new GraphicsLocationConverter(); + } + + internal class SourceTypeConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(SourceType); + + public override SourceType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "dynamic": + return SourceType.Dynamic; + case "http": + return SourceType.Http; + case "lavfi": + return SourceType.Lavfi; + case "local": + return SourceType.Local; + case "rtsp": + return SourceType.Rtsp; + case "script": + return SourceType.Script; + } + throw new Exception("Cannot unmarshal type SourceType"); + } + + public override void Write(Utf8JsonWriter writer, SourceType value, JsonSerializerOptions options) + { + switch (value) + { + case SourceType.Dynamic: + JsonSerializer.Serialize(writer, "dynamic", options); + return; + case SourceType.Http: + JsonSerializer.Serialize(writer, "http", options); + return; + case SourceType.Lavfi: + JsonSerializer.Serialize(writer, "lavfi", options); + return; + case SourceType.Local: + JsonSerializer.Serialize(writer, "local", options); + return; + case SourceType.Rtsp: + JsonSerializer.Serialize(writer, "rtsp", options); + return; + case SourceType.Script: + JsonSerializer.Serialize(writer, "script", options); + return; + } + throw new Exception("Cannot marshal type SourceType"); + } + + public static readonly SourceTypeConverter Singleton = new SourceTypeConverter(); } internal class PeriodicClockConverter : JsonConverter diff --git a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs index 41592d65c..098132df8 100644 --- a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs @@ -499,70 +499,70 @@ public class PlayoutItemConverter( playoutItem.StartOffset, shouldLogMessages: false); - // single, permanent or intermittent watermarks are supported - if (watermarks.Count == 1 && watermarks.All(wm => - wm.Watermark.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.Intermittent)) + // permanent or intermittent watermarks are supported + if (watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.Intermittent)) { - foreach (WatermarkOptions watermarkOptions in watermarks) + nextPlayoutItem.Graphics = []; + + foreach (WatermarkOptions watermarkOptions in watermarks.OrderBy(wm => wm.Watermark.ZIndex)) { - if (nextPlayoutItem.Watermark is null) + Core.Next.GraphicsLocation location = watermarkOptions.Watermark.Location switch { - Core.Next.WatermarkLocation location = watermarkOptions.Watermark.Location switch - { - WatermarkLocation.TopMiddle => Core.Next.WatermarkLocation.TopCenter, - WatermarkLocation.TopRight => Core.Next.WatermarkLocation.TopRight, - WatermarkLocation.LeftMiddle => Core.Next.WatermarkLocation.CenterLeft, - WatermarkLocation.MiddleCenter => Core.Next.WatermarkLocation.Center, - WatermarkLocation.RightMiddle => Core.Next.WatermarkLocation.CenterRight, - WatermarkLocation.BottomLeft => Core.Next.WatermarkLocation.BottomLeft, - WatermarkLocation.BottomMiddle => Core.Next.WatermarkLocation.BottomCenter, - WatermarkLocation.BottomRight => Core.Next.WatermarkLocation.BottomRight, - _ => Core.Next.WatermarkLocation.TopLeft, - }; + WatermarkLocation.TopMiddle => Core.Next.GraphicsLocation.TopCenter, + WatermarkLocation.TopRight => Core.Next.GraphicsLocation.TopRight, + WatermarkLocation.LeftMiddle => Core.Next.GraphicsLocation.CenterLeft, + WatermarkLocation.MiddleCenter => Core.Next.GraphicsLocation.Center, + WatermarkLocation.RightMiddle => Core.Next.GraphicsLocation.CenterRight, + WatermarkLocation.BottomLeft => Core.Next.GraphicsLocation.BottomLeft, + WatermarkLocation.BottomMiddle => Core.Next.GraphicsLocation.BottomCenter, + WatermarkLocation.BottomRight => Core.Next.GraphicsLocation.BottomRight, + _ => Core.Next.GraphicsLocation.TopLeft, + }; - nextPlayoutItem.Watermark = new Core.Next.Watermark - { - Location = location, - HorizontalMarginPercent = watermarkOptions.Watermark.HorizontalMarginPercent, - VerticalMarginPercent = watermarkOptions.Watermark.VerticalMarginPercent, - OpacityPercent = watermarkOptions.Watermark.Opacity, - StreamIndex = await watermarkOptions.ImageStreamIndex.IfNoneAsync(0), - WithinSourceContent = watermarkOptions.Watermark.PlaceWithinSourceContent, - }; + var layer = new Core.Next.GraphicsLayer + { + Location = location, + HorizontalMarginPercent = watermarkOptions.Watermark.HorizontalMarginPercent, + VerticalMarginPercent = watermarkOptions.Watermark.VerticalMarginPercent, + OpacityPercent = watermarkOptions.Watermark.Opacity, + StreamIndex = await watermarkOptions.ImageStreamIndex.IfNoneAsync(0), + WithinSourceContent = watermarkOptions.Watermark.PlaceWithinSourceContent, + }; - if (watermarkOptions.Watermark.Size is WatermarkSize.Scaled) - { - nextPlayoutItem.Watermark.WidthPercent = watermarkOptions.Watermark.WidthPercent; - } + if (watermarkOptions.Watermark.Size is WatermarkSize.Scaled) + { + layer.WidthPercent = watermarkOptions.Watermark.WidthPercent; + } - if (watermarkOptions.ImagePath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (watermarkOptions.ImagePath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + layer.Source = new Core.Next.PlayoutItemSource { - nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource - { - SourceType = Core.Next.SourceType.Http, - Uri = watermarkOptions.ImagePath, - }; - } - else + SourceType = Core.Next.SourceType.Http, + Uri = watermarkOptions.ImagePath, + }; + } + else + { + layer.Source = new Core.Next.PlayoutItemSource { - nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource - { - SourceType = Core.Next.SourceType.Local, - Path = watermarkOptions.ImagePath, - }; - } + SourceType = Core.Next.SourceType.Local, + Path = watermarkOptions.ImagePath, + }; + } - if (watermarkOptions.Watermark.Mode is ChannelWatermarkMode.Intermittent) + if (watermarkOptions.Watermark.Mode is ChannelWatermarkMode.Intermittent) + { + layer.Timing = new Core.Next.Timing { - nextPlayoutItem.Watermark.Timing = new Core.Next.Timing - { - TimingType = Core.Next.TimingType.Periodic, - Clock = Core.Next.PeriodicClock.Wall, - FrequencyMs = watermarkOptions.Watermark.FrequencyMinutes * 60 * 1000, - HoldMs = watermarkOptions.Watermark.DurationSeconds * 1000, - }; - } + TimingType = Core.Next.TimingType.Periodic, + Clock = Core.Next.PeriodicClock.Wall, + FrequencyMs = watermarkOptions.Watermark.FrequencyMinutes * 60 * 1000, + HoldMs = watermarkOptions.Watermark.DurationSeconds * 1000, + }; } + + nextPlayoutItem.Graphics.Add(layer); } } } diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index a7da94c47..ac3f83d6f 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -57,11 +57,6 @@ public class TroubleshootController( { Option ss = seekSeconds > 0 ? seekSeconds : Option.None; - if (streamingEngine is StreamingEngine.Next && watermark.Count > 1) - { - watermark = [watermark.Head()]; - } - Either result = await mediator.Send( new PrepareTroubleshootingPlayback( sessionId, diff --git a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor index 319335c7e..5e643c05d 100644 --- a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor +++ b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor @@ -117,16 +117,9 @@
- @if (_streamingEngine is StreamingEngine.Legacy) - { - Watermarks - } - else - { - Watermark - } + Watermarks
- + @foreach (WatermarkViewModel watermark in _watermarks) { @watermark.Name