From 91b029e429b233b1f2c77e84ee6f100679686a98 Mon Sep 17 00:00:00 2001
From: Jason Dove <1695733+jasongdove@users.noreply.github.com>
Date: Sun, 3 May 2026 12:04:59 -0500
Subject: [PATCH] feat: overlay single intermittent watermarks using next
engine (#2879)
---
.../Commands/SyncNextPlayoutHandler.cs | 16 +-
ErsatzTV.Core/Next/Playout.cs | 149 ++++++++++++++++++
2 files changed, 163 insertions(+), 2 deletions(-)
diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
index ff7a18e37..d0cdae501 100644
--- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
+++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
@@ -395,8 +395,9 @@ public partial class SyncNextPlayoutHandler(
playoutItem.StartOffset,
shouldLogMessages: false);
- // single, permanent watermarks are supported
- if (watermarks.Count == 1 && watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent))
+ // single, permanent or intermittent watermarks are supported
+ if (watermarks.Count == 1 && watermarks.All(wm =>
+ wm.Watermark.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.Intermittent))
{
foreach (WatermarkOptions watermarkOptions in watermarks)
{
@@ -445,6 +446,17 @@ public partial class SyncNextPlayoutHandler(
Path = watermarkOptions.ImagePath,
};
}
+
+ if (watermarkOptions.Watermark.Mode is ChannelWatermarkMode.Intermittent)
+ {
+ 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,
+ };
+ }
}
}
}
diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs
index 85067e233..5abcb6821 100644
--- a/ErsatzTV.Core/Next/Playout.cs
+++ b/ErsatzTV.Core/Next/Playout.cs
@@ -257,6 +257,12 @@ namespace ErsatzTV.Core.Next
[JsonProperty("stream_index")]
public long? StreamIndex { get; set; }
+ ///
+ /// Visibility schedule for the watermark. Omit for an always-on watermark.
+ ///
+ [JsonProperty("timing")]
+ public Timing Timing { get; set; }
+
///
/// Vertical offset from the anchor `location`, as a percent of primary content height
/// (0–100). Omit for 0.
@@ -352,6 +358,61 @@ namespace ErsatzTV.Core.Next
public string UserAgent { get; set; }
}
+ ///
+ /// 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`.
+ ///
+ public partial class Timing
+ {
+ ///
+ /// Reference clock used to position cycles.
+ ///
+ [JsonProperty("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.
+ ///
+ [JsonProperty("disable_after_ms")]
+ public long? DisableAfterMs { get; set; }
+
+ ///
+ /// Fade-in and fade-out duration in milliseconds (symmetric). Must be ≤ `hold_ms`. Omit for
+ /// 1000; set to 0 for hard cuts.
+ ///
+ [JsonProperty("fade_ms")]
+ public long? FadeMs { get; set; }
+
+ ///
+ /// Period of the cycle, start-to-start, in milliseconds.
+ ///
+ [JsonProperty("frequency_ms")]
+ public long FrequencyMs { get; set; }
+
+ ///
+ /// Time held fully visible between fade-in and fade-out, in milliseconds.
+ ///
+ [JsonProperty("hold_ms")]
+ public long HoldMs { 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.
+ ///
+ [JsonProperty("phase_offset_ms")]
+ public long? PhaseOffsetMs { get; set; }
+
+ [JsonProperty("timing_type")]
+ public TimingType TimingType { get; set; }
+ }
+
public enum SourceType { Http, Lavfi, Local };
///
@@ -362,6 +423,17 @@ namespace ErsatzTV.Core.Next
///
public enum WatermarkLocation { BottomCenter, BottomLeft, BottomRight, Center, CenterLeft, CenterRight, TopCenter, TopLeft, TopRight };
+ ///
+ /// Reference clock used to position cycles.
+ ///
+ /// Reference clock for periodic timing. `wall` aligns cycles to wall-clock time (so a viewer
+ /// tuning in at any moment sees the same phase). `content` measures from the start of the
+ /// containing playout item.
+ ///
+ public enum PeriodicClock { Content, Wall };
+
+ public enum TimingType { Periodic };
+
public partial class Playout
{
public static Playout FromJson(string json) => JsonConvert.DeserializeObject(json, ErsatzTV.Core.Next.Converter.Settings);
@@ -383,6 +455,8 @@ namespace ErsatzTV.Core.Next
{
SourceTypeConverter.Singleton,
WatermarkLocationConverter.Singleton,
+ PeriodicClockConverter.Singleton,
+ TimingTypeConverter.Singleton,
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
},
};
@@ -543,4 +617,79 @@ namespace ErsatzTV.Core.Next
public static readonly WatermarkLocationConverter Singleton = new WatermarkLocationConverter();
}
+
+ internal class PeriodicClockConverter : JsonConverter
+ {
+ public override bool CanConvert(Type t) => t == typeof(PeriodicClock) || t == typeof(PeriodicClock?);
+
+ public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ {
+ if (reader.TokenType == JsonToken.Null) return null;
+ var value = serializer.Deserialize(reader);
+ switch (value)
+ {
+ case "content":
+ return PeriodicClock.Content;
+ case "wall":
+ return PeriodicClock.Wall;
+ }
+ throw new Exception("Cannot unmarshal type PeriodicClock");
+ }
+
+ public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ {
+ if (untypedValue == null)
+ {
+ serializer.Serialize(writer, null);
+ return;
+ }
+ var value = (PeriodicClock)untypedValue;
+ switch (value)
+ {
+ case PeriodicClock.Content:
+ serializer.Serialize(writer, "content");
+ return;
+ case PeriodicClock.Wall:
+ serializer.Serialize(writer, "wall");
+ return;
+ }
+ throw new Exception("Cannot marshal type PeriodicClock");
+ }
+
+ public static readonly PeriodicClockConverter Singleton = new PeriodicClockConverter();
+ }
+
+ internal class TimingTypeConverter : JsonConverter
+ {
+ public override bool CanConvert(Type t) => t == typeof(TimingType) || t == typeof(TimingType?);
+
+ public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ {
+ if (reader.TokenType == JsonToken.Null) return null;
+ var value = serializer.Deserialize(reader);
+ if (value == "periodic")
+ {
+ return TimingType.Periodic;
+ }
+ throw new Exception("Cannot unmarshal type TimingType");
+ }
+
+ public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ {
+ if (untypedValue == null)
+ {
+ serializer.Serialize(writer, null);
+ return;
+ }
+ var value = (TimingType)untypedValue;
+ if (value == TimingType.Periodic)
+ {
+ serializer.Serialize(writer, "periodic");
+ return;
+ }
+ throw new Exception("Cannot marshal type TimingType");
+ }
+
+ public static readonly TimingTypeConverter Singleton = new TimingTypeConverter();
+ }
}