Browse Source

optionally place watermark within source content (#986)

pull/987/head
Jason Dove 4 years ago committed by GitHub
parent
commit
6f892bea6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs
  3. 3
      ErsatzTV.Application/Watermarks/Commands/CreateWatermarkHandler.cs
  4. 3
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs
  5. 3
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs
  6. 3
      ErsatzTV.Application/Watermarks/Mapper.cs
  7. 2
      ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksHandler.cs
  8. 3
      ErsatzTV.Application/Watermarks/WatermarkViewModel.cs
  9. 1
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  10. 3
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  11. 6
      ErsatzTV.FFmpeg.Tests/Filter/WatermarkOpacityFilterTests.cs
  12. 45
      ErsatzTV.FFmpeg/Filter/OverlayWatermarkFilter.cs
  13. 3
      ErsatzTV.FFmpeg/State/WatermarkState.cs
  14. 4312
      ErsatzTV.Infrastructure/Migrations/20221009130454_Add_WatermarkPlaceWithinSourceContent.Designer.cs
  15. 26
      ErsatzTV.Infrastructure/Migrations/20221009130454_Add_WatermarkPlaceWithinSourceContent.cs
  16. 3
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  17. 43
      ErsatzTV/Pages/WatermarkEditor.razor
  18. 10
      ErsatzTV/ViewModels/WatermarkEditViewModel.cs

4
CHANGELOG.md

@ -27,6 +27,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `duration`: the timespan duration of the music video, which can be used to calculate timing of additional subtitles - `duration`: the timespan duration of the music video, which can be used to calculate timing of additional subtitles
- `stream_seek`: the timespan that ffmpeg will seek into the media item before beginning playback - `stream_seek`: the timespan that ffmpeg will seek into the media item before beginning playback
### Changed
- No longer place watermarks within content by default (e.g. within 4:3 content padded to a 16:9 resolution)
- This can be re-enabled if desired using the `Place Within Source Content` checkbox in watermark settings
## [0.6.8-beta] - 2022-10-05 ## [0.6.8-beta] - 2022-10-05
### Fixed ### Fixed
- Fix typo introduced in `0.6.7-beta` that stopped QSV HEVC encoder from working - Fix typo introduced in `0.6.7-beta` that stopped QSV HEVC encoder from working

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

@ -16,6 +16,7 @@ public record CreateWatermark(
int VerticalMargin, int VerticalMargin,
int FrequencyMinutes, int FrequencyMinutes,
int DurationSeconds, int DurationSeconds,
int Opacity) : IRequest<Either<BaseError, CreateWatermarkResult>>; int Opacity,
bool PlaceWithinSourceContent) : 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

@ -46,7 +46,8 @@ public class CreateWatermarkHandler : IRequestHandler<CreateWatermark, Either<Ba
VerticalMarginPercent = request.VerticalMargin, VerticalMarginPercent = request.VerticalMargin,
FrequencyMinutes = request.FrequencyMinutes, FrequencyMinutes = request.FrequencyMinutes,
DurationSeconds = request.DurationSeconds, DurationSeconds = request.DurationSeconds,
Opacity = request.Opacity Opacity = request.Opacity,
PlaceWithinSourceContent = request.PlaceWithinSourceContent
}); });
private static Validation<BaseError, string> ValidateName(CreateWatermark request) => private static Validation<BaseError, string> ValidateName(CreateWatermark request) =>

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

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

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

@ -19,7 +19,7 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ChannelWatermark> validation = await Validate(dbContext, request); Validation<BaseError, ChannelWatermark> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, p => ApplyUpdateRequest(dbContext, p, request)); return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
} }
private static async Task<UpdateWatermarkResult> ApplyUpdateRequest( private static async Task<UpdateWatermarkResult> ApplyUpdateRequest(
@ -39,6 +39,7 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
p.FrequencyMinutes = update.FrequencyMinutes; p.FrequencyMinutes = update.FrequencyMinutes;
p.DurationSeconds = update.DurationSeconds; p.DurationSeconds = update.DurationSeconds;
p.Opacity = update.Opacity; p.Opacity = update.Opacity;
p.PlaceWithinSourceContent = update.PlaceWithinSourceContent;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return new UpdateWatermarkResult(p.Id); return new UpdateWatermarkResult(p.Id);
} }

3
ErsatzTV.Application/Watermarks/Mapper.cs

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

2
ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksHandler.cs

@ -15,7 +15,7 @@ public class GetAllWatermarksHandler : IRequestHandler<GetAllWatermarks, List<Wa
GetAllWatermarks request, GetAllWatermarks request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ChannelWatermarks return await dbContext.ChannelWatermarks
.ToListAsync(cancellationToken) .ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList()); .Map(list => list.Map(ProjectToViewModel).ToList());

3
ErsatzTV.Application/Watermarks/WatermarkViewModel.cs

@ -16,5 +16,6 @@ public record WatermarkViewModel(
int VerticalMargin, int VerticalMargin,
int FrequencyMinutes, int FrequencyMinutes,
int DurationSeconds, int DurationSeconds,
int Opacity int Opacity,
bool PlaceWithinSourceContent
); );

1
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -17,6 +17,7 @@ public class ChannelWatermark
public int FrequencyMinutes { get; set; } public int FrequencyMinutes { get; set; }
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 enum ChannelWatermarkMode public enum ChannelWatermarkMode

3
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -546,7 +546,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermark.WidthPercent, watermark.WidthPercent,
watermark.HorizontalMarginPercent, watermark.HorizontalMarginPercent,
watermark.VerticalMarginPercent, watermark.VerticalMarginPercent,
watermark.Opacity)); watermark.Opacity,
watermark.PlaceWithinSourceContent));
return watermarkInputFile; return watermarkInputFile;
} }

6
ErsatzTV.FFmpeg.Tests/Filter/WatermarkOpacityFilterTests.cs

@ -23,7 +23,8 @@ public class WatermarkOpacityFilterTests
50, 50,
50, 50,
50, 50,
75)); 75,
false));
filter.Filter.Should().Be("colorchannelmixer=aa=0.75"); filter.Filter.Should().Be("colorchannelmixer=aa=0.75");
} }
@ -40,7 +41,8 @@ public class WatermarkOpacityFilterTests
50, 50,
50, 50,
50, 50,
75)); 75,
false));
filter.Filter.Should().Be("colorchannelmixer=aa=0.75"); filter.Filter.Should().Be("colorchannelmixer=aa=0.75");
} }

45
ErsatzTV.FFmpeg/Filter/OverlayWatermarkFilter.cs

@ -28,20 +28,9 @@ public class OverlayWatermarkFilter : BaseFilter
{ {
get get
{ {
int horizontalPadding = _resolution.Width - _squarePixelFrameSize.Width; (double horizontalMargin, double verticalMargin) = _watermarkState.PlaceWithinSourceContent
int verticalPadding = _resolution.Height - _squarePixelFrameSize.Height; ? SourceContentMargins()
: NormalMargins();
_logger.LogDebug(
$"Resolution: {_resolution.Width}x{_resolution.Height}");
_logger.LogDebug(
$"Square Pix: {_squarePixelFrameSize.Width}x{_squarePixelFrameSize.Height}");
double horizontalMargin = Math.Round(
_watermarkState.HorizontalMarginPercent / 100.0 * _squarePixelFrameSize.Width
+ horizontalPadding / 2.0);
double verticalMargin = Math.Round(
_watermarkState.VerticalMarginPercent / 100.0 * _squarePixelFrameSize.Height
+ verticalPadding / 2.0);
return _watermarkState.Location switch return _watermarkState.Location switch
{ {
@ -58,4 +47,32 @@ public class OverlayWatermarkFilter : BaseFilter
} }
public override FrameState NextState(FrameState currentState) => currentState; public override FrameState NextState(FrameState currentState) => currentState;
private WatermarkMargins NormalMargins()
{
double horizontalMargin = Math.Round(_watermarkState.HorizontalMarginPercent / 100.0 * _resolution.Width);
double verticalMargin = Math.Round(_watermarkState.VerticalMarginPercent / 100.0 * _resolution.Height);
return new WatermarkMargins(horizontalMargin, verticalMargin);
}
private WatermarkMargins SourceContentMargins()
{
int horizontalPadding = _resolution.Width - _squarePixelFrameSize.Width;
int verticalPadding = _resolution.Height - _squarePixelFrameSize.Height;
_logger.LogDebug("Resolution: {Width}x{Height}", _resolution.Width, _resolution.Height);
_logger.LogDebug("Square Pix: {Width}x{Height}", _squarePixelFrameSize.Width, _squarePixelFrameSize.Height);
double horizontalMargin = Math.Round(
_watermarkState.HorizontalMarginPercent / 100.0 * _squarePixelFrameSize.Width
+ horizontalPadding / 2.0);
double verticalMargin = Math.Round(
_watermarkState.VerticalMarginPercent / 100.0 * _squarePixelFrameSize.Height
+ verticalPadding / 2.0);
return new WatermarkMargins(horizontalMargin, verticalMargin);
}
private record WatermarkMargins(double HorizontalMargin, double VerticalMargin);
} }

3
ErsatzTV.FFmpeg/State/WatermarkState.cs

@ -7,7 +7,8 @@ public record WatermarkState(
int WidthPercent, int WidthPercent,
int HorizontalMarginPercent, int HorizontalMarginPercent,
int VerticalMarginPercent, int VerticalMarginPercent,
int Opacity); int Opacity,
bool PlaceWithinSourceContent);
public record WatermarkFadePoint(TimeSpan Time, TimeSpan EnableStart, TimeSpan EnableFinish); public record WatermarkFadePoint(TimeSpan Time, TimeSpan EnableStart, TimeSpan EnableFinish);

4312
ErsatzTV.Infrastructure/Migrations/20221009130454_Add_WatermarkPlaceWithinSourceContent.Designer.cs generated

File diff suppressed because it is too large Load Diff

26
ErsatzTV.Infrastructure/Migrations/20221009130454_Add_WatermarkPlaceWithinSourceContent.cs

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_WatermarkPlaceWithinSourceContent : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "PlaceWithinSourceContent",
table: "ChannelWatermark",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PlaceWithinSourceContent",
table: "ChannelWatermark");
}
}
}

3
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -313,6 +313,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("Opacity") b.Property<int>("Opacity")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("PlaceWithinSourceContent")
.HasColumnType("INTEGER");
b.Property<int>("Size") b.Property<int>("Size")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

43
ErsatzTV/Pages/WatermarkEditor.razor

@ -4,10 +4,10 @@
@using ErsatzTV.Application.Watermarks @using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Images @using ErsatzTV.Application.Images
@implements IDisposable @implements IDisposable
@inject NavigationManager _navigationManager @inject NavigationManager NavigationManager
@inject ILogger<WatermarkEditor> _logger @inject ILogger<WatermarkEditor> Logger
@inject ISnackbar _snackbar @inject ISnackbar Snackbar
@inject IMediator _mediator @inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;"> <div style="max-width: 400px;">
@ -65,6 +65,10 @@
<MudSelectItem Value="@(WatermarkLocation.TopRight)">Top Right</MudSelectItem> <MudSelectItem Value="@(WatermarkLocation.TopRight)">Top Right</MudSelectItem>
<MudSelectItem Value="@(WatermarkLocation.RightMiddle)">Right Middle</MudSelectItem> <MudSelectItem Value="@(WatermarkLocation.RightMiddle)">Right Middle</MudSelectItem>
</MudSelect> </MudSelect>
<MudCheckBox Class="mt-3" Label="Place Within Source Content"
@bind-Checked="_model.PlaceWithinSourceContent"
For="@(() => _model.PlaceWithinSourceContent)"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)" />
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center"> <MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6"> <MudItem xs="6">
<MudSelect Label="Size" @bind-Value="_model.Size" <MudSelect Label="Size" @bind-Value="_model.Size"
@ -165,10 +169,10 @@
{ {
if (IsEdit) if (IsEdit)
{ {
Option<WatermarkViewModel> watermark = await _mediator.Send(new GetWatermarkById(Id), _cts.Token); Option<WatermarkViewModel> watermark = await Mediator.Send(new GetWatermarkById(Id), _cts.Token);
watermark.Match( watermark.Match(
watermarkViewModel => _model = new WatermarkEditViewModel(watermarkViewModel), watermarkViewModel => _model = new WatermarkEditViewModel(watermarkViewModel),
() => _navigationManager.NavigateTo("404")); () => NavigationManager.NavigateTo("404"));
} }
else else
{ {
@ -184,7 +188,8 @@
VerticalMargin = 5, VerticalMargin = 5,
FrequencyMinutes = 15, FrequencyMinutes = 15,
DurationSeconds = 15, DurationSeconds = 15,
Opacity = 100 Opacity = 100,
PlaceWithinSourceContent = false
}; };
} }
@ -200,16 +205,16 @@
if (_editContext.Validate()) if (_editContext.Validate())
{ {
Seq<BaseError> errorMessage = IsEdit ? Seq<BaseError> errorMessage = IsEdit ?
(await _mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq() : (await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq() :
(await _mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq(); (await Mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
errorMessage.HeadOrNone().Match( errorMessage.HeadOrNone().Match(
error => error =>
{ {
_snackbar.Add("Unexpected error saving watermark"); Snackbar.Add("Unexpected error saving watermark");
_logger.LogError("Unexpected error saving watermark: {Error}", error.Value); Logger.LogError("Unexpected error saving watermark: {Error}", error.Value);
}, },
() => _navigationManager.NavigateTo("/watermarks")); () => NavigationManager.NavigateTo("/watermarks"));
} }
} }
@ -217,7 +222,7 @@
{ {
try try
{ {
Either<BaseError, string> maybeCacheFileName = await _mediator.Send( Either<BaseError, string> maybeCacheFileName = await Mediator.Send(
new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Watermark), new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Watermark),
_cts.Token); _cts.Token);
maybeCacheFileName.Match( maybeCacheFileName.Match(
@ -230,19 +235,19 @@
}, },
error => error =>
{ {
_snackbar.Add($"Unexpected error saving watermark: {error.Value}", Severity.Error); Snackbar.Add($"Unexpected error saving watermark: {error.Value}", Severity.Error);
_logger.LogError("Unexpected error saving watermark: {Error}", error.Value); Logger.LogError("Unexpected error saving watermark: {Error}", error.Value);
}); });
} }
catch (IOException) catch (IOException)
{ {
_snackbar.Add("Watermark exceeds maximum allowed file size of 10 MB", Severity.Error); Snackbar.Add("Watermark exceeds maximum allowed file size of 10 MB", Severity.Error);
_logger.LogError("Watermark exceeds maximum allowed file size of 10 MB"); Logger.LogError("Watermark exceeds maximum allowed file size of 10 MB");
} }
catch (Exception ex) catch (Exception ex)
{ {
_snackbar.Add($"Unexpected error saving watermark: {ex.Message}", Severity.Error); Snackbar.Add($"Unexpected error saving watermark: {ex.Message}", Severity.Error);
_logger.LogError("Unexpected error saving watermark: {Error}", ex.Message); Logger.LogError("Unexpected error saving watermark: {Error}", ex.Message);
} }
} }

10
ErsatzTV/ViewModels/WatermarkEditViewModel.cs

@ -25,6 +25,7 @@ public class WatermarkEditViewModel
FrequencyMinutes = vm.FrequencyMinutes; FrequencyMinutes = vm.FrequencyMinutes;
DurationSeconds = vm.DurationSeconds; DurationSeconds = vm.DurationSeconds;
Opacity = vm.Opacity; Opacity = vm.Opacity;
PlaceWithinSourceContent = vm.PlaceWithinSourceContent;
} }
public int Id { get; set; } public int Id { get; set; }
@ -40,6 +41,7 @@ public class WatermarkEditViewModel
public int FrequencyMinutes { get; set; } public int FrequencyMinutes { get; set; }
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 CreateWatermark ToCreate() => public CreateWatermark ToCreate() =>
new( new(
@ -54,8 +56,8 @@ public class WatermarkEditViewModel
VerticalMargin, VerticalMargin,
FrequencyMinutes, FrequencyMinutes,
DurationSeconds, DurationSeconds,
Opacity Opacity,
); PlaceWithinSourceContent);
public UpdateWatermark ToUpdate() => public UpdateWatermark ToUpdate() =>
new( new(
@ -71,6 +73,6 @@ public class WatermarkEditViewModel
VerticalMargin, VerticalMargin,
FrequencyMinutes, FrequencyMinutes,
DurationSeconds, DurationSeconds,
Opacity Opacity,
); PlaceWithinSourceContent);
} }

Loading…
Cancel
Save