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/). @@ -6,8 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add *experimental* graphics engine
- Permanent watermarks will use new graphics engine
- Intermittent watermarks will still use normal overlay pipeline (for now)
- `Permanent` watermarks will use new graphics engine
- `Opacity Expression` watermarks will use new graphics engine
- `Intermittent` watermarks will still use normal overlay pipeline (for now)
- 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
### Added

5
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

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

3
ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -18,6 +18,7 @@ public record CreateWatermark( @@ -18,6 +18,7 @@ public record CreateWatermark(
int FrequencyMinutes,
int DurationSeconds,
int Opacity,
bool PlaceWithinSourceContent) : IRequest<Either<BaseError, CreateWatermarkResult>>;
bool PlaceWithinSourceContent,
string OpacityExpression) : IRequest<Either<BaseError, CreateWatermarkResult>>;
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 @@ -55,7 +55,8 @@ public class CreateWatermarkHandler : IRequestHandler<CreateWatermark, Either<Ba
FrequencyMinutes = request.FrequencyMinutes,
DurationSeconds = request.DurationSeconds,
Opacity = request.Opacity,
PlaceWithinSourceContent = request.PlaceWithinSourceContent
PlaceWithinSourceContent = request.PlaceWithinSourceContent,
OpacityExpression = request.OpacityExpression
};
if (request.ImageSource == ChannelWatermarkImageSource.Custom)

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

@ -19,6 +19,7 @@ public record UpdateWatermark( @@ -19,6 +19,7 @@ public record UpdateWatermark(
int FrequencyMinutes,
int DurationSeconds,
int Opacity,
bool PlaceWithinSourceContent) : IRequest<Either<BaseError, UpdateWatermarkResult>>;
bool PlaceWithinSourceContent,
string OpacityExpression) : IRequest<Either<BaseError, UpdateWatermarkResult>>;
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 @@ -53,6 +53,8 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
p.DurationSeconds = update.DurationSeconds;
p.Opacity = update.Opacity;
p.PlaceWithinSourceContent = update.PlaceWithinSourceContent;
p.OpacityExpression = update.Mode is ChannelWatermarkMode.OpacityExpression ? update.OpacityExpression : null;
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return new UpdateWatermarkResult(p.Id);

3
ErsatzTV.Application/Watermarks/Mapper.cs

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

3
ErsatzTV.Application/Watermarks/WatermarkViewModel.cs

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

4
ErsatzTV.Core/Domain/ChannelWatermark.cs

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

12
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

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

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

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

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

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

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

@ -4,7 +4,14 @@ namespace ErsatzTV.Core.Interfaces.Streaming; @@ -4,7 +4,14 @@ namespace ErsatzTV.Core.Interfaces.Streaming;
public interface IGraphicsElement
{
bool IsFailed { get; set; }
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 @@ @@ -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 @@ -379,6 +379,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("Opacity")
.HasColumnType("int");
b.Property<string>("OpacityExpression")
.HasColumnType("longtext");
b.Property<string>("OriginalContentType")
.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 @@ @@ -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 @@ -364,6 +364,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("Opacity")
.HasColumnType("INTEGER");
b.Property<string>("OpacityExpression")
.HasColumnType("TEXT");
b.Property<string>("OriginalContentType")
.HasColumnType("TEXT");

31
ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs

@ -1,12 +1,13 @@ @@ -1,12 +1,13 @@
using System.IO.Pipelines;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
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)
{
@ -36,7 +37,18 @@ public class GraphicsEngine : IGraphicsEngine @@ -36,7 +37,18 @@ public class GraphicsEngine : IGraphicsEngine
{
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>(
context.FrameSize.Width,
@ -48,7 +60,20 @@ public class GraphicsEngine : IGraphicsEngine @@ -48,7 +60,20 @@ public class GraphicsEngine : IGraphicsEngine
{
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 @@ @@ -1,7 +1,9 @@
using System.Globalization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg.State;
using NCalc;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Processing;
@ -16,6 +18,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -16,6 +18,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable
private readonly List<Image> _scaledFrames = [];
private readonly List<double> _frameDelays = [];
private Expression _expression;
private double _animatedDurationSeconds;
private Image _sourceImage;
private Point _location;
@ -36,8 +39,20 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -36,8 +39,20 @@ public class WatermarkElement : IGraphicsElement, IDisposable
public bool IsValid => _imagePath != null && _watermark != null;
public bool IsFailed { get; set; }
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)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
@ -73,13 +88,12 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -73,13 +88,12 @@ public class WatermarkElement : IGraphicsElement, IDisposable
horizontalMargin,
verticalMargin);
float opacity = _watermark.Opacity / 100.0f;
_animatedDurationSeconds = 0;
for (int i = 0; i < _sourceImage.Frames.Count; 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);
var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0;
@ -88,17 +102,32 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -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)
{
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
imageProcessingContext.DrawImage(frameForTimestamp, _location, 1f);
Image frameForTimestamp = GetFrameForTimestamp(contentTime);
imageProcessingContext.DrawImage(frameForTimestamp, _location, opacity);
}
private Image GetFrameForTimestamp(TimeSpan timestamp)

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

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

1
ErsatzTV/Controllers/InternalController.cs

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

18
ErsatzTV/Pages/WatermarkEditor.razor

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
<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">
<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>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@ -34,6 +34,7 @@ @@ -34,6 +34,7 @@
<MudSelectItem Value="@(ChannelWatermarkMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.Permanent)">Permanent</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.Intermittent)">Intermittent</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.OpacityExpression)">Opacity Expression</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
@ -51,7 +52,7 @@ @@ -51,7 +52,7 @@
<div class="d-flex">
<MudText>Image</MudText>
</div>
<InputFile id="watermarkFileInput" OnChange="UploadWatermark" style="display: none;"/>
<InputFile id="watermarkFileInput" OnChange="@UploadWatermark" style="display: none;"/>
<MudButton HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
@ -176,9 +177,17 @@ @@ -176,9 +177,17 @@
For="@(() => _model.Opacity)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)"
Disabled="@(_model.Mode is ChannelWatermarkMode.None or ChannelWatermarkMode.OpacityExpression)"
Immediate="true"/>
</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>
</div>
</MudForm>
@ -223,7 +232,8 @@ @@ -223,7 +232,8 @@
FrequencyMinutes = 15,
DurationSeconds = 15,
Opacity = 100,
PlaceWithinSourceContent = false
PlaceWithinSourceContent = false,
OpacityExpression = string.Empty
};
}
}

8
ErsatzTV/ViewModels/WatermarkEditViewModel.cs

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

Loading…
Cancel
Save