Browse Source

add watermark opacity expression (#2266)

* add watermark opacity expression

* implement watermark opacity expression parameters

* minor fixes
pull/2269/head
Jason Dove 1 year ago committed by GitHub
parent
commit
a2fc99229e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  3. 3
      ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs
  4. 1
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs
  5. 1
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs
  6. 1
      ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs
  7. 1
      ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs
  8. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  9. 1
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  10. 1
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs
  11. 1
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  12. 3
      ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs
  13. 3
      ErsatzTV.Application/Watermarks/Commands/CreateWatermarkHandler.cs
  14. 3
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs
  15. 2
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs
  16. 3
      ErsatzTV.Application/Watermarks/Mapper.cs
  17. 3
      ErsatzTV.Application/Watermarks/WatermarkViewModel.cs
  18. 4
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  19. 12
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  20. 1
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  21. 3
      ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs
  22. 9
      ErsatzTV.Core/Interfaces/Streaming/IGraphicsElement.cs
  23. 6179
      ErsatzTV.Infrastructure.MySql/Migrations/20250806161001_Add_ChannelWatermarkOpacityExpression.Designer.cs
  24. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20250806161001_Add_ChannelWatermarkOpacityExpression.cs
  25. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  26. 6016
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806160643_Add_ChannelWatermarkOpacityExpression.Designer.cs
  27. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250806160643_Add_ChannelWatermarkOpacityExpression.cs
  28. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  29. 31
      ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs
  30. 41
      ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs
  31. 2
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  32. 1
      ErsatzTV/Controllers/InternalController.cs
  33. 18
      ErsatzTV/Pages/WatermarkEditor.razor
  34. 8
      ErsatzTV/ViewModels/WatermarkEditViewModel.cs

11
CHANGELOG.md

@ -6,8 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Add *experimental* graphics engine - Add *experimental* graphics engine
- Permanent watermarks will use new graphics engine - `Permanent` watermarks will use new graphics engine
- Intermittent watermarks will still use normal overlay pipeline (for now) - `Opacity Expression` watermarks will use new graphics engine
- `Intermittent` watermarks will still use normal overlay pipeline (for now)
- Add `Opacity Expression` watermark mode
- This allows specifying an expression that returns an opacity between 0.0 and 1.0
- The expression can use:
- `content_seconds` - the total number of seconds the frame is into the content
- `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
## [25.4.0] - 2025-08-05 ## [25.4.0] - 2025-08-05
### Added ### Added

5
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -32,7 +32,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private readonly ILogger<HlsSessionWorker> _logger; private readonly ILogger<HlsSessionWorker> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly SemaphoreSlim _slim = new(1, 1); private readonly SemaphoreSlim _slim = new(1, 1);
private readonly object _sync = new(); private readonly Lock _sync = new();
private readonly Option<int> _targetFramerate; private readonly Option<int> _targetFramerate;
private CancellationTokenSource _cancellationTokenSource; private CancellationTokenSource _cancellationTokenSource;
private string _channelNumber; private string _channelNumber;
@ -44,6 +44,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private HlsSessionState _state; private HlsSessionState _state;
private Timer _timer; private Timer _timer;
private DateTimeOffset _transcodedUntil; private DateTimeOffset _transcodedUntil;
private DateTimeOffset _channelStart;
public HlsSessionWorker( public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
@ -180,6 +181,7 @@ public class HlsSessionWorker : IHlsSessionWorker
Touch(); Touch();
_transcodedUntil = DateTimeOffset.Now; _transcodedUntil = DateTimeOffset.Now;
PlaylistStart = _transcodedUntil; PlaylistStart = _transcodedUntil;
_channelStart = _transcodedUntil;
// time shift on-demand playout if needed // time shift on-demand playout if needed
await _mediator.Send( await _mediator.Send(
@ -428,6 +430,7 @@ public class HlsSessionWorker : IHlsSessionWorker
now, now,
startAtZero, startAtZero,
realtime, realtime,
_channelStart,
ptsOffset, ptsOffset,
_targetFramerate); _targetFramerate);

3
ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs

@ -35,6 +35,7 @@ public class HlsSessionWorkerV2 : IHlsSessionWorker
private HlsSessionState _state; private HlsSessionState _state;
private Timer _timer; private Timer _timer;
private DateTimeOffset _transcodedUntil; private DateTimeOffset _transcodedUntil;
private DateTimeOffset _channelStart;
public HlsSessionWorkerV2( public HlsSessionWorkerV2(
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
@ -127,6 +128,7 @@ public class HlsSessionWorkerV2 : IHlsSessionWorker
Touch(); Touch();
_transcodedUntil = DateTimeOffset.Now; _transcodedUntil = DateTimeOffset.Now;
PlaylistStart = _transcodedUntil; PlaylistStart = _transcodedUntil;
_channelStart = _transcodedUntil;
// time shift on-demand playout if needed // time shift on-demand playout if needed
await _mediator.Send( await _mediator.Send(
@ -307,6 +309,7 @@ public class HlsSessionWorkerV2 : IHlsSessionWorker
_transcodedUntil, _transcodedUntil,
startAtZero, startAtZero,
realtime, realtime,
_channelStart,
0, 0,
_targetFramerate); _targetFramerate);

1
ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs

@ -8,4 +8,5 @@ public record FFmpegProcessRequest(
DateTimeOffset Now, DateTimeOffset Now,
bool StartAtZero, bool StartAtZero,
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStartTime,
long PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>; long PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;

1
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs

@ -8,6 +8,7 @@ public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
DateTimeOffset.Now, DateTimeOffset.Now,
false, false,
true, true,
DateTimeOffset.Now, // unused
0) 0)
{ {
Scheme = scheme; Scheme = scheme;

1
ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs

@ -8,6 +8,7 @@ public record GetConcatSegmenterProcessByChannelNumber : FFmpegProcessRequest
DateTimeOffset.Now, DateTimeOffset.Now,
false, false,
true, true,
DateTimeOffset.Now, // unused
0) 0)
{ {
Scheme = scheme; Scheme = scheme;

1
ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs

@ -13,4 +13,5 @@ public record GetErrorProcess(
DateTimeOffset.Now, DateTimeOffset.Now,
true, true,
HlsRealtime, HlsRealtime,
DateTimeOffset.Now, // unused
PtsOffset); PtsOffset);

2
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs

@ -6,6 +6,7 @@ public record GetPlayoutItemProcessByChannelNumber(
DateTimeOffset Now, DateTimeOffset Now,
bool StartAtZero, bool StartAtZero,
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStart,
long PtsOffset, long PtsOffset,
Option<int> TargetFramerate) : FFmpegProcessRequest( Option<int> TargetFramerate) : FFmpegProcessRequest(
ChannelNumber, ChannelNumber,
@ -13,4 +14,5 @@ public record GetPlayoutItemProcessByChannelNumber(
Now, Now,
StartAtZero, StartAtZero,
HlsRealtime, HlsRealtime,
ChannelStart,
PtsOffset); PtsOffset);

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

@ -346,6 +346,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItemWithPath.PlayoutItem.FillerKind, playoutItemWithPath.PlayoutItem.FillerKind,
inPoint, inPoint,
outPoint, outPoint,
request.ChannelStartTime,
request.PtsOffset, request.PtsOffset,
request.TargetFramerate, request.TargetFramerate,
disableWatermarks, disableWatermarks,

1
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs

@ -12,6 +12,7 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
DateTimeOffset.Now, DateTimeOffset.Now,
false, false,
true, true,
DateTimeOffset.Now, // unused
0) 0)
{ {
Scheme = scheme; Scheme = scheme;

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

@ -143,6 +143,7 @@ public class PrepareTroubleshootingPlaybackHandler(
FillerKind.None, FillerKind.None,
inPoint, inPoint,
outPoint, outPoint,
channelStartTime: DateTimeOffset.Now,
0, 0,
None, None,
false, false,

3
ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs

@ -18,6 +18,7 @@ public record CreateWatermark(
int FrequencyMinutes, int FrequencyMinutes,
int DurationSeconds, int DurationSeconds,
int Opacity, int Opacity,
bool PlaceWithinSourceContent) : IRequest<Either<BaseError, CreateWatermarkResult>>; bool PlaceWithinSourceContent,
string OpacityExpression) : IRequest<Either<BaseError, CreateWatermarkResult>>;
public record CreateWatermarkResult(int WatermarkId) : EntityIdResult(WatermarkId); public record CreateWatermarkResult(int WatermarkId) : EntityIdResult(WatermarkId);

3
ErsatzTV.Application/Watermarks/Commands/CreateWatermarkHandler.cs

@ -55,7 +55,8 @@ public class CreateWatermarkHandler : IRequestHandler<CreateWatermark, Either<Ba
FrequencyMinutes = request.FrequencyMinutes, FrequencyMinutes = request.FrequencyMinutes,
DurationSeconds = request.DurationSeconds, DurationSeconds = request.DurationSeconds,
Opacity = request.Opacity, Opacity = request.Opacity,
PlaceWithinSourceContent = request.PlaceWithinSourceContent PlaceWithinSourceContent = request.PlaceWithinSourceContent,
OpacityExpression = request.OpacityExpression
}; };
if (request.ImageSource == ChannelWatermarkImageSource.Custom) if (request.ImageSource == ChannelWatermarkImageSource.Custom)

3
ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs

@ -19,6 +19,7 @@ public record UpdateWatermark(
int FrequencyMinutes, int FrequencyMinutes,
int DurationSeconds, int DurationSeconds,
int Opacity, int Opacity,
bool PlaceWithinSourceContent) : IRequest<Either<BaseError, UpdateWatermarkResult>>; bool PlaceWithinSourceContent,
string OpacityExpression) : IRequest<Either<BaseError, UpdateWatermarkResult>>;
public record UpdateWatermarkResult(int WatermarkId) : EntityIdResult(WatermarkId); public record UpdateWatermarkResult(int WatermarkId) : EntityIdResult(WatermarkId);

2
ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs

@ -53,6 +53,8 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
p.DurationSeconds = update.DurationSeconds; p.DurationSeconds = update.DurationSeconds;
p.Opacity = update.Opacity; p.Opacity = update.Opacity;
p.PlaceWithinSourceContent = update.PlaceWithinSourceContent; p.PlaceWithinSourceContent = update.PlaceWithinSourceContent;
p.OpacityExpression = update.Mode is ChannelWatermarkMode.OpacityExpression ? update.OpacityExpression : null;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged(); _searchTargets.SearchTargetsChanged();
return new UpdateWatermarkResult(p.Id); return new UpdateWatermarkResult(p.Id);

3
ErsatzTV.Application/Watermarks/Mapper.cs

@ -20,5 +20,6 @@ internal static class Mapper
watermark.FrequencyMinutes, watermark.FrequencyMinutes,
watermark.DurationSeconds, watermark.DurationSeconds,
watermark.Opacity, watermark.Opacity,
watermark.PlaceWithinSourceContent); watermark.PlaceWithinSourceContent,
watermark.OpacityExpression);
} }

3
ErsatzTV.Application/Watermarks/WatermarkViewModel.cs

@ -18,5 +18,6 @@ public record WatermarkViewModel(
int FrequencyMinutes, int FrequencyMinutes,
int DurationSeconds, int DurationSeconds,
int Opacity, int Opacity,
bool PlaceWithinSourceContent bool PlaceWithinSourceContent,
string OpacityExpression
); );

4
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -19,13 +19,15 @@ public class ChannelWatermark
public int DurationSeconds { get; set; } public int DurationSeconds { get; set; }
public int Opacity { get; set; } public int Opacity { get; set; }
public bool PlaceWithinSourceContent { get; set; } public bool PlaceWithinSourceContent { get; set; }
public string OpacityExpression { get; set; }
} }
public enum ChannelWatermarkMode public enum ChannelWatermarkMode
{ {
None = 0, None = 0,
Permanent = 1, Permanent = 1,
Intermittent = 2 Intermittent = 2,
OpacityExpression = 3
} }
public enum ChannelWatermarkImageSource public enum ChannelWatermarkImageSource

12
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -73,6 +73,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FillerKind fillerKind, FillerKind fillerKind,
TimeSpan inPoint, TimeSpan inPoint,
TimeSpan outPoint, TimeSpan outPoint,
DateTimeOffset channelStartTime,
long ptsOffset, long ptsOffset,
Option<int> targetFramerate, Option<int> targetFramerate,
bool disableWatermarks, bool disableWatermarks,
@ -348,8 +349,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None; Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None; Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
// use graphics engine for permanent watermarks // use graphics engine for permanent watermarks, or opacity expressions
foreach (var options in watermarkOptions.Where(o => o.Watermark.Map(wm => wm.Mode is ChannelWatermarkMode.Permanent).IfNone(false))) var maybeGraphicsEngineWatermark = watermarkOptions
.Where(o => o.Watermark
.Map(wm => wm.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.OpacityExpression)
.IfNone(false));
foreach (var options in maybeGraphicsEngineWatermark)
{ {
watermarkInputFile = Option<WatermarkInputFile>.None; watermarkInputFile = Option<WatermarkInputFile>.None;
@ -361,6 +366,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
[watermark], [watermark],
channel.FFmpegProfile.Resolution, channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24), await playbackSettings.FrameRate.IfNoneAsync(24),
ChannelStartTime: channelStartTime,
ContentStartTime: start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now); finish - now);
} }

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

@ -37,6 +37,7 @@ public interface IFFmpegProcessService
FillerKind fillerKind, FillerKind fillerKind,
TimeSpan inPoint, TimeSpan inPoint,
TimeSpan outPoint, TimeSpan outPoint,
DateTimeOffset channelStartTime,
long ptsOffset, long ptsOffset,
Option<int> targetFramerate, Option<int> targetFramerate,
bool disableWatermarks, bool disableWatermarks,

3
ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs

@ -7,6 +7,9 @@ public record GraphicsEngineContext(
List<GraphicsElementContext> Elements, List<GraphicsElementContext> Elements,
Resolution FrameSize, Resolution FrameSize,
int FrameRate, int FrameRate,
DateTimeOffset ChannelStartTime,
DateTimeOffset ContentStartTime,
TimeSpan Seek,
TimeSpan Duration); TimeSpan Duration);
public abstract record GraphicsElementContext; public abstract record GraphicsElementContext;

9
ErsatzTV.Core/Interfaces/Streaming/IGraphicsElement.cs

@ -4,7 +4,14 @@ namespace ErsatzTV.Core.Interfaces.Streaming;
public interface IGraphicsElement public interface IGraphicsElement
{ {
bool IsFailed { get; set; }
Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken); Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken);
void Draw(object context, TimeSpan timestamp); void Draw(
object context,
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan channelTime,
CancellationToken cancellationToken);
} }

6179
ErsatzTV.Infrastructure.MySql/Migrations/20250806161001_Add_ChannelWatermarkOpacityExpression.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20250806161001_Add_ChannelWatermarkOpacityExpression.cs

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelWatermarkOpacityExpression : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OpacityExpression",
table: "ChannelWatermark",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OpacityExpression",
table: "ChannelWatermark");
}
}
}

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

@ -379,6 +379,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("Opacity") b.Property<int>("Opacity")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("OpacityExpression")
.HasColumnType("longtext");
b.Property<string>("OriginalContentType") b.Property<string>("OriginalContentType")
.HasColumnType("longtext"); .HasColumnType("longtext");

6016
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806160643_Add_ChannelWatermarkOpacityExpression.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20250806160643_Add_ChannelWatermarkOpacityExpression.cs

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelWatermarkOpacityExpression : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OpacityExpression",
table: "ChannelWatermark",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OpacityExpression",
table: "ChannelWatermark");
}
}
}

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

@ -364,6 +364,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("Opacity") b.Property<int>("Opacity")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("OpacityExpression")
.HasColumnType("TEXT");
b.Property<string>("OriginalContentType") b.Property<string>("OriginalContentType")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

31
ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs

@ -1,12 +1,13 @@
using System.IO.Pipelines; using System.IO.Pipelines;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
namespace ErsatzTV.Infrastructure.Streaming; namespace ErsatzTV.Infrastructure.Streaming;
public class GraphicsEngine : IGraphicsEngine public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
{ {
public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken) public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken)
{ {
@ -36,7 +37,18 @@ public class GraphicsEngine : IGraphicsEngine
{ {
while (!cancellationToken.IsCancellationRequested && frameCount < totalFrames) while (!cancellationToken.IsCancellationRequested && frameCount < totalFrames)
{ {
var timestamp = TimeSpan.FromSeconds(frameCount / context.FrameRate); // seconds since this specific stream started
double streamTimeSeconds = (double)frameCount / context.FrameRate;
var streamTime = TimeSpan.FromSeconds(streamTimeSeconds);
// `content_seconds` - the total number of seconds the frame is into the content
var contentTime = context.Seek + streamTime;
// `time_of_day_seconds` - the total number of seconds the frame is since midnight
var frameTime = context.ContentStartTime + contentTime;
// `channel_seconds` - the total number of seconds the frame is from when the channel started/activated
var channelTime = frameTime - context.ChannelStartTime;
using var outputFrame = new Image<Bgra32>( using var outputFrame = new Image<Bgra32>(
context.FrameSize.Width, context.FrameSize.Width,
@ -48,7 +60,20 @@ public class GraphicsEngine : IGraphicsEngine
{ {
foreach (var element in elements) foreach (var element in elements)
{ {
element.Draw(ctx, timestamp); try
{
if (!element.IsFailed)
{
element.Draw(ctx, frameTime.TimeOfDay, contentTime, channelTime, cancellationToken);
}
}
catch (Exception ex)
{
element.IsFailed = true;
logger.LogWarning(ex,
"Failed to draw graphics element of type {Type}; will disable for this content",
element.GetType().Name);
}
} }
}); });

41
ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs

@ -1,7 +1,9 @@
using System.Globalization;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg.State; using ErsatzTV.FFmpeg.State;
using NCalc;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
@ -16,6 +18,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable
private readonly List<Image> _scaledFrames = []; private readonly List<Image> _scaledFrames = [];
private readonly List<double> _frameDelays = []; private readonly List<double> _frameDelays = [];
private Expression _expression;
private double _animatedDurationSeconds; private double _animatedDurationSeconds;
private Image _sourceImage; private Image _sourceImage;
private Point _location; private Point _location;
@ -36,8 +39,20 @@ public class WatermarkElement : IGraphicsElement, IDisposable
public bool IsValid => _imagePath != null && _watermark != null; public bool IsValid => _imagePath != null && _watermark != null;
public bool IsFailed { get; set; }
public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken) public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
{ {
if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
{
_expression = new Expression(_watermark.OpacityExpression);
}
else
{
float opacity = _watermark.Opacity / 100.0f;
_expression = new Expression(opacity.ToString(CultureInfo.InvariantCulture));
}
bool isRemoteUri = Uri.TryCreate(_imagePath, UriKind.Absolute, out var uriResult) bool isRemoteUri = Uri.TryCreate(_imagePath, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
@ -73,13 +88,12 @@ public class WatermarkElement : IGraphicsElement, IDisposable
horizontalMargin, horizontalMargin,
verticalMargin); verticalMargin);
float opacity = _watermark.Opacity / 100.0f;
_animatedDurationSeconds = 0; _animatedDurationSeconds = 0;
for (int i = 0; i < _sourceImage.Frames.Count; i++) for (int i = 0; i < _sourceImage.Frames.Count; i++)
{ {
var frame = _sourceImage.Frames.CloneFrame(i); var frame = _sourceImage.Frames.CloneFrame(i);
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight).Opacity(opacity)); frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight));
_scaledFrames.Add(frame); _scaledFrames.Add(frame);
var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0; var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0;
@ -88,17 +102,32 @@ public class WatermarkElement : IGraphicsElement, IDisposable
} }
} }
public void Draw(object context, TimeSpan timestamp) public void Draw(
object context,
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan channelTime,
CancellationToken cancellationToken)
{ {
if (context is not IImageProcessingContext imageProcessingContext) if (context is not IImageProcessingContext imageProcessingContext)
{ {
return; return;
} }
Image frameForTimestamp = GetFrameForTimestamp(timestamp); _expression.Parameters["content_seconds"] = contentTime.TotalSeconds;
_expression.Parameters["channel_seconds"] = channelTime.TotalSeconds;
_expression.Parameters["time_of_day_seconds"] = timeOfDay.TotalSeconds;
object expressionResult = _expression.Evaluate();
float opacity = Convert.ToSingle(expressionResult, CultureInfo.InvariantCulture);
Console.WriteLine("opacity is " + opacity);
if (opacity == 0)
{
return;
}
// scaled frames already have opacity set Image frameForTimestamp = GetFrameForTimestamp(contentTime);
imageProcessingContext.DrawImage(frameForTimestamp, _location, 1f); imageProcessingContext.DrawImage(frameForTimestamp, _location, opacity);
} }
private Image GetFrameForTimestamp(TimeSpan timestamp) private Image GetFrameForTimestamp(TimeSpan timestamp)

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

@ -378,6 +378,7 @@ public class TranscodingTests
FillerKind.None, FillerKind.None,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3),
DateTimeOffset.Now,
0, 0,
None, None,
false, false,
@ -656,6 +657,7 @@ public class TranscodingTests
FillerKind.None, FillerKind.None,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3),
DateTimeOffset.Now,
0, 0,
None, None,
false, false,

1
ErsatzTV/Controllers/InternalController.cs

@ -272,6 +272,7 @@ public class InternalController : ControllerBase
DateTimeOffset.Now, DateTimeOffset.Now,
false, false,
true, true,
DateTimeOffset.Now,
0, 0,
Option<int>.None); Option<int>.None);

18
ErsatzTV/Pages/WatermarkEditor.razor

@ -14,7 +14,7 @@
<MudForm Model="@_model" @ref="@_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" Style="max-height: 100%"> <MudForm Model="@_model" @ref="@_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center"> <MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="ml-6" OnClick="HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Watermark" : "Add Watermark")</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" Class="ml-6" OnClick="@HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Watermark" : "Add Watermark")</MudButton>
</MudPaper> </MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto"> <div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@ -34,6 +34,7 @@
<MudSelectItem Value="@(ChannelWatermarkMode.None)">None</MudSelectItem> <MudSelectItem Value="@(ChannelWatermarkMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.Permanent)">Permanent</MudSelectItem> <MudSelectItem Value="@(ChannelWatermarkMode.Permanent)">Permanent</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.Intermittent)">Intermittent</MudSelectItem> <MudSelectItem Value="@(ChannelWatermarkMode.Intermittent)">Intermittent</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.OpacityExpression)">Opacity Expression</MudSelectItem>
</MudSelect> </MudSelect>
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
@ -51,7 +52,7 @@
<div class="d-flex"> <div class="d-flex">
<MudText>Image</MudText> <MudText>Image</MudText>
</div> </div>
<InputFile id="watermarkFileInput" OnChange="UploadWatermark" style="display: none;"/> <InputFile id="watermarkFileInput" OnChange="@UploadWatermark" style="display: none;"/>
<MudButton HtmlTag="label" <MudButton HtmlTag="label"
Variant="Variant.Filled" Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
@ -176,9 +177,17 @@
For="@(() => _model.Opacity)" For="@(() => _model.Opacity)"
Adornment="Adornment.End" Adornment="Adornment.End"
AdornmentText="%" AdornmentText="%"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)" Disabled="@(_model.Mode is ChannelWatermarkMode.None or ChannelWatermarkMode.OpacityExpression)"
Immediate="true"/> Immediate="true"/>
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Opacity Expression</MudText>
</div>
<MudTextField @bind-Value="_model.OpacityExpression"
For="@(() => _model.OpacityExpression)"
Disabled="@(_model.Mode != ChannelWatermarkMode.OpacityExpression)"/>
</MudStack>
</MudContainer> </MudContainer>
</div> </div>
</MudForm> </MudForm>
@ -223,7 +232,8 @@
FrequencyMinutes = 15, FrequencyMinutes = 15,
DurationSeconds = 15, DurationSeconds = 15,
Opacity = 100, Opacity = 100,
PlaceWithinSourceContent = false PlaceWithinSourceContent = false,
OpacityExpression = string.Empty
}; };
} }
} }

8
ErsatzTV/ViewModels/WatermarkEditViewModel.cs

@ -27,6 +27,7 @@ public class WatermarkEditViewModel
DurationSeconds = vm.DurationSeconds; DurationSeconds = vm.DurationSeconds;
Opacity = vm.Opacity; Opacity = vm.Opacity;
PlaceWithinSourceContent = vm.PlaceWithinSourceContent; PlaceWithinSourceContent = vm.PlaceWithinSourceContent;
OpacityExpression = vm.OpacityExpression;
} }
public int Id { get; set; } public int Id { get; set; }
@ -43,6 +44,7 @@ public class WatermarkEditViewModel
public int DurationSeconds { get; set; } public int DurationSeconds { get; set; }
public int Opacity { get; set; } public int Opacity { get; set; }
public bool PlaceWithinSourceContent { get; set; } public bool PlaceWithinSourceContent { get; set; }
public string OpacityExpression { get; set; }
public CreateWatermark ToCreate() => public CreateWatermark ToCreate() =>
new( new(
@ -58,7 +60,8 @@ public class WatermarkEditViewModel
FrequencyMinutes, FrequencyMinutes,
DurationSeconds, DurationSeconds,
Opacity, Opacity,
PlaceWithinSourceContent); PlaceWithinSourceContent,
OpacityExpression);
public UpdateWatermark ToUpdate() => public UpdateWatermark ToUpdate() =>
new( new(
@ -75,5 +78,6 @@ public class WatermarkEditViewModel
FrequencyMinutes, FrequencyMinutes,
DurationSeconds, DurationSeconds,
Opacity, Opacity,
PlaceWithinSourceContent); PlaceWithinSourceContent,
OpacityExpression);
} }

Loading…
Cancel
Save