Browse Source

feat: support multiple watermarks using next engine (#2983)

pull/2984/head
Jason Dove 1 month ago committed by GitHub
parent
commit
3a02c39b11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  3. 6
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  4. 466
      ErsatzTV.Core/Next/Playout.cs
  5. 106
      ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs
  6. 5
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  7. 11
      ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

4
CHANGELOG.md

@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

2
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -245,7 +245,7 @@ public partial class SyncNextPlayoutHandler( @@ -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<Core.Next.PlayoutItem> maybeNextPlayoutItem = await playoutItemConverter.ToNext(

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

@ -335,7 +335,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -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( @@ -352,7 +352,7 @@ public class PrepareTroubleshootingPlaybackHandler(
Option<Core.Next.PlayoutItem> 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( @@ -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]
};

466
ErsatzTV.Core/Next/Playout.cs

@ -64,6 +64,14 @@ namespace ErsatzTV.Core.Next @@ -64,6 +64,14 @@ namespace ErsatzTV.Core.Next
[JsonPropertyName("finish")]
public DateTimeOffset Finish { get; set; }
/// <summary>
/// Ordered media-backed graphics layers. The first entry is lowest and the last entry is
/// highest; a compatibility `watermark` is always below this array.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("graphics")]
public List<GraphicsLayer>? Graphics { get; set; }
/// <summary>
/// Stable identifier for this item, unique within the playout.
/// </summary>
@ -91,14 +99,93 @@ namespace ErsatzTV.Core.Next @@ -91,14 +99,93 @@ namespace ErsatzTV.Core.Next
public PlayoutItemTracks? Tracks { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("watermark")]
public Watermark? Watermark { get; set; }
public GraphicsLayer? Watermark { get; set; }
}
/// <summary>
/// An image or video graphics layer composited on top of the primary content. Sized and
/// positioned relative to the primary content's frame.
/// </summary>
public partial class GraphicsLayer
{
/// <summary>
/// Horizontal offset from the anchor `location`, as a percent of primary content width
/// (0–100). Omit for 0.
/// </summary>
[JsonPropertyName("horizontal_margin_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? HorizontalMarginPercent { get; set; }
/// <summary>
/// Anchor position within the primary content frame.
/// </summary>
[JsonPropertyName("location")]
public GraphicsLocation Location { get; set; }
/// <summary>
/// Opacity as a percent (0–100). Omit for fully opaque (100).
/// </summary>
[JsonPropertyName("opacity_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? OpacityPercent { get; set; }
/// <summary>
/// The source providing the graphics media (typically an image, but any `PlayoutItemSource`
/// is accepted).
/// </summary>
[JsonPropertyName("source")]
public PlayoutItemSource Source { get; set; }
/// <summary>
/// Zero-based stream index within the source. If omitted, the server picks the first video
/// stream.
/// </summary>
[JsonPropertyName("stream_index")]
public long? StreamIndex { get; set; }
/// <summary>
/// Visibility schedule for the graphics layer. Omit for an always-on layer.
/// </summary>
[JsonPropertyName("timing")]
public Timing? Timing { get; set; }
/// <summary>
/// Vertical offset from the anchor `location`, as a percent of primary content height
/// (0–100). Omit for 0.
/// </summary>
[JsonPropertyName("vertical_margin_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? VerticalMarginPercent { get; set; }
/// <summary>
/// Scale the graphics layer to this percent of the primary content width (0–100). Omit to
/// use its actual size.
/// </summary>
[JsonPropertyName("width_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? WidthPercent { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("within_source_content")]
public bool? WithinSourceContent { get; set; }
}
/// <summary>
/// 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 @@ -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).
/// </summary>
public partial class Source
public partial class PlayoutItemSource
{
/// <summary>
/// Optional start offset into the source, in milliseconds.
@ -454,135 +541,61 @@ namespace ErsatzTV.Core.Next @@ -454,135 +541,61 @@ namespace ErsatzTV.Core.Next
}
/// <summary>
/// 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).
/// </summary>
public partial class PlayoutItemTracks
{
/// <summary>
/// Audio track selection.
/// </summary>
[JsonPropertyName("audio")]
public TrackSelection? Audio { get; set; }
/// <summary>
/// Subtitle track selection.
/// </summary>
[JsonPropertyName("subtitle")]
public TrackSelection? Subtitle { get; set; }
/// <summary>
/// Video track selection.
/// </summary>
[JsonPropertyName("video")]
public TrackSelection? Video { get; set; }
}
/// <summary>
/// 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.
/// </summary>
public partial class TrackSelection
{
/// <summary>
/// Source to pull this track from. If omitted, inherits from the parent `PlayoutItem.source`.
/// </summary>
[JsonPropertyName("source")]
public Source? Source { get; set; }
/// <summary>
/// Zero-based stream index within the effective source. If omitted, the server picks the
/// first stream of this track's kind.
/// </summary>
[JsonPropertyName("stream_index")]
public long? StreamIndex { get; set; }
}
/// <summary>
/// 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`.
/// </summary>
public partial class Watermark
public partial class Timing
{
/// <summary>
/// Horizontal offset from the anchor `location`, as a percent of primary content width
/// (0–100). Omit for 0.
/// </summary>
[JsonPropertyName("horizontal_margin_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? HorizontalMarginPercent { get; set; }
/// <summary>
/// Anchor position within the primary content frame.
/// </summary>
[JsonPropertyName("location")]
public WatermarkLocation Location { get; set; }
/// <summary>
/// Opacity as a percent (0–100). Omit for fully opaque (100).
/// Reference clock used to position cycles.
/// </summary>
[JsonPropertyName("opacity_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? OpacityPercent { get; set; }
[JsonPropertyName("clock")]
public PeriodicClock Clock { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("source")]
public PlayoutItemSource Source { get; set; }
[JsonPropertyName("disable_after_ms")]
public long? DisableAfterMs { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("stream_index")]
public long? StreamIndex { get; set; }
[JsonPropertyName("fade_ms")]
public long? FadeMs { get; set; }
/// <summary>
/// Visibility schedule for the watermark. Omit for an always-on watermark.
/// Period of the cycle, start-to-start, in milliseconds.
/// </summary>
[JsonPropertyName("timing")]
public Timing? Timing { get; set; }
[JsonPropertyName("frequency_ms")]
public long FrequencyMs { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("vertical_margin_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? VerticalMarginPercent { get; set; }
[JsonPropertyName("hold_ms")]
public long HoldMs { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("width_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? WidthPercent { get; set; }
[JsonPropertyName("phase_offset_ms")]
public long? PhaseOffsetMs { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("within_source_content")]
public bool? WithinSourceContent { get; set; }
[JsonPropertyName("timing_type")]
public TimingType TimingType { get; set; }
}
/// <summary>
/// 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 @@ -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).
/// </summary>
public partial class PlayoutItemSource
public partial class Source
{
/// <summary>
/// Optional start offset into the source, in milliseconds.
@ -737,69 +750,64 @@ namespace ErsatzTV.Core.Next @@ -737,69 +750,64 @@ namespace ErsatzTV.Core.Next
}
/// <summary>
/// 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).
/// </summary>
public partial class Timing
public partial class PlayoutItemTracks
{
/// <summary>
/// Reference clock used to position cycles.
/// </summary>
[JsonPropertyName("clock")]
public PeriodicClock Clock { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("disable_after_ms")]
public long? DisableAfterMs { get; set; }
[JsonPropertyName("audio")]
public TrackSelection? Audio { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("fade_ms")]
public long? FadeMs { get; set; }
[JsonPropertyName("subtitle")]
public TrackSelection? Subtitle { get; set; }
/// <summary>
/// Period of the cycle, start-to-start, in milliseconds.
/// Video track selection.
/// </summary>
[JsonPropertyName("frequency_ms")]
public long FrequencyMs { get; set; }
[JsonPropertyName("video")]
public TrackSelection? Video { get; set; }
}
/// <summary>
/// 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.
/// </summary>
public partial class TrackSelection
{
/// <summary>
/// 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`.
/// </summary>
[JsonPropertyName("hold_ms")]
public long HoldMs { get; set; }
[JsonPropertyName("source")]
public Source? Source { get; set; }
/// <summary>
/// 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.
/// </summary>
[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 };
/// <summary>
/// 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`.
/// </summary>
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 };
/// <summary>
/// Reference clock used to position cycles.
@ -829,8 +837,8 @@ namespace ErsatzTV.Core.Next @@ -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 @@ -840,60 +848,6 @@ namespace ErsatzTV.Core.Next
};
}
internal class SourceTypeConverter : JsonConverter<SourceType>
{
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<double>
{
public override bool CanConvert(Type t) => t == typeof(double);
@ -921,73 +875,127 @@ namespace ErsatzTV.Core.Next @@ -921,73 +875,127 @@ namespace ErsatzTV.Core.Next
public static readonly MinMaxValueCheckConverter Singleton = new MinMaxValueCheckConverter();
}
internal class WatermarkLocationConverter : JsonConverter<WatermarkLocation>
internal class GraphicsLocationConverter : JsonConverter<GraphicsLocation>
{
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<SourceType>
{
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<PeriodicClock>

106
ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs

@ -499,70 +499,70 @@ public class PlayoutItemConverter( @@ -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);
}
}
}

5
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -57,11 +57,6 @@ public class TroubleshootController( @@ -57,11 +57,6 @@ public class TroubleshootController(
{
Option<int> ss = seekSeconds > 0 ? seekSeconds : Option<int>.None;
if (streamingEngine is StreamingEngine.Next && watermark.Count > 1)
{
watermark = [watermark.Head()];
}
Either<BaseError, PlayoutItemResult> result = await mediator.Send(
new PrepareTroubleshootingPlayback(
sessionId,

11
ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

@ -117,16 +117,9 @@ @@ -117,16 +117,9 @@
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
@if (_streamingEngine is StreamingEngine.Legacy)
{
<MudText>Watermarks</MudText>
}
else
{
<MudText>Watermark</MudText>
}
<MudText>Watermarks</MudText>
</div>
<MudSelect T="string" @bind-SelectedValues="_watermarkNames" Clearable="true" MultiSelection="_streamingEngine is StreamingEngine.Legacy">
<MudSelect T="string" @bind-SelectedValues="_watermarkNames" Clearable="true" MultiSelection="true">
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="string" Value="@watermark.Name">@watermark.Name</MudSelectItem>

Loading…
Cancel
Save