feat(67): one logo drives both the listing and the on-screen bug #504
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.Watermarks;
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
|
||||
new(watermark.Id, watermark.Name);
|
||||
new(watermark.Id, watermark.Name, watermark.ImageSource);
|
||||
|
||||
internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) =>
|
||||
new(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Watermarks;
|
||||
|
||||
public record WatermarkResponseModel(int Id, string Name);
|
||||
// ImageSource lets a client identify logo-driven presets (the seeded "Channel Bug") without
|
||||
// matching a user-editable name. Additive under the frozen /api/v1 contract (#286).
|
||||
public record WatermarkResponseModel(int Id, string Name, ChannelWatermarkImageSource ImageSource);
|
||||
|
||||
@@ -24,6 +24,7 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
|
||||
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
|
||||
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
|
||||
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
|
||||
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
|
||||
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
|
||||
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data;
|
||||
@@ -131,13 +132,17 @@ public static class DbInitializer
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await SeedChannelTemplates(context, cancellationToken);
|
||||
int? channelBugWatermarkId = await SeedChannelBugWatermark(context, cancellationToken);
|
||||
await SeedChannelTemplates(context, cancellationToken, channelBugWatermarkId);
|
||||
|
||||
// TODO: create looping static image that mentions configuring via web
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task SeedChannelTemplates(TvContext context, CancellationToken cancellationToken)
|
||||
private static async Task SeedChannelTemplates(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken,
|
||||
int? channelBugWatermarkId)
|
||||
{
|
||||
if (await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken) &&
|
||||
await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken))
|
||||
@@ -162,7 +167,8 @@ public static class DbInitializer
|
||||
ChannelMusicVideoCreditsMode.None,
|
||||
ChannelSongVideoMode.Default,
|
||||
shuffleScheduleItems: false,
|
||||
randomStartPoint: false),
|
||||
randomStartPoint: false,
|
||||
channelBugWatermarkId),
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -177,7 +183,8 @@ public static class DbInitializer
|
||||
ChannelMusicVideoCreditsMode.GenerateSubtitles,
|
||||
ChannelSongVideoMode.WithProgress,
|
||||
shuffleScheduleItems: true,
|
||||
randomStartPoint: true),
|
||||
randomStartPoint: true,
|
||||
channelBugWatermarkId),
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -185,6 +192,60 @@ public static class DbInitializer
|
||||
await EnsureDefaultChannelTemplateConfig(context, cancellationToken);
|
||||
}
|
||||
|
||||
// A single shared preset is all that's needed: ImageSource.ChannelLogo resolves each channel's
|
||||
// own logo artwork at render time (WatermarkSelector), so one row makes every channel use its
|
||||
// own logo as its on-screen bug.
|
||||
//
|
||||
// Guarded by a ConfigElement marker rather than by name alone: ChannelWatermark has no IsSystem
|
||||
// flag, and Initialize runs on every startup, so a name-only guard would resurrect the row
|
||||
// forever after a deliberate delete. Adopting an existing same-name row (an operator's tuned
|
||||
// one) also sets the marker — adopt, never overwrite.
|
||||
private static async Task<int?> SeedChannelBugWatermark(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string seededKey = ConfigElementKey.WatermarkChannelBugSeeded.Key;
|
||||
bool alreadySeeded = await context.ConfigElements
|
||||
.AnyAsync(c => c.Key == seededKey, cancellationToken);
|
||||
|
||||
ChannelWatermark existing = await context.ChannelWatermarks
|
||||
.FirstOrDefaultAsync(w => w.Name == "Channel Bug", cancellationToken);
|
||||
|
||||
if (alreadySeeded)
|
||||
{
|
||||
return existing?.Id;
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new ChannelWatermark
|
||||
{
|
||||
Name = "Channel Bug",
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
|
||||
Image = null,
|
||||
Location = WatermarkLocation.TopLeft,
|
||||
Size = WatermarkSize.Scaled,
|
||||
WidthPercent = 5.0,
|
||||
HorizontalMarginPercent = 1.0,
|
||||
VerticalMarginPercent = 1.0,
|
||||
FrequencyMinutes = 0,
|
||||
DurationSeconds = 0,
|
||||
Opacity = 80,
|
||||
PlaceWithinSourceContent = false,
|
||||
ZIndex = 0
|
||||
};
|
||||
await context.ChannelWatermarks.AddAsync(existing, cancellationToken);
|
||||
}
|
||||
|
||||
await context.ConfigElements.AddAsync(
|
||||
new ConfigElement { Key = seededKey, Value = "true" },
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return existing.Id;
|
||||
}
|
||||
|
||||
private static async Task<FFmpegProfile> GetDefaultFFmpegProfile(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -237,7 +298,8 @@ public static class DbInitializer
|
||||
ChannelMusicVideoCreditsMode musicVideoCreditsMode,
|
||||
ChannelSongVideoMode songVideoMode,
|
||||
bool shuffleScheduleItems,
|
||||
bool randomStartPoint) =>
|
||||
bool randomStartPoint,
|
||||
int? watermarkId) =>
|
||||
new()
|
||||
{
|
||||
Name = name,
|
||||
@@ -260,6 +322,7 @@ public static class DbInitializer
|
||||
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
|
||||
ShuffleScheduleItems = shuffleScheduleItems,
|
||||
RandomStartPoint = randomStartPoint,
|
||||
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible
|
||||
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible,
|
||||
WatermarkId = watermarkId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ public class WatermarkHandlerTests
|
||||
public async Task GetAllWatermarksForApi_Should_Return_All_Watermarks()
|
||||
{
|
||||
await SeedWatermark(1, "Bug");
|
||||
await SeedWatermark(2, "Logo");
|
||||
await SeedWatermark(2, "Logo", ChannelWatermarkImageSource.ChannelLogo);
|
||||
|
||||
var handler = new GetAllWatermarksForApiHandler(_db.Factory);
|
||||
|
||||
@@ -32,8 +32,10 @@ public class WatermarkHandlerTests
|
||||
await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(new WatermarkResponseModel(1, "Bug"));
|
||||
result.ShouldContain(new WatermarkResponseModel(2, "Logo"));
|
||||
// Distinct image sources so the assertion proves ImageSource is actually carried through
|
||||
// the mapper, rather than matching a constant on both rows.
|
||||
result.ShouldContain(new WatermarkResponseModel(1, "Bug", ChannelWatermarkImageSource.Custom));
|
||||
result.ShouldContain(new WatermarkResponseModel(2, "Logo", ChannelWatermarkImageSource.ChannelLogo));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -47,7 +49,10 @@ public class WatermarkHandlerTests
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private async Task SeedWatermark(int id, string name)
|
||||
private async Task SeedWatermark(
|
||||
int id,
|
||||
string name,
|
||||
ChannelWatermarkImageSource imageSource = ChannelWatermarkImageSource.Custom)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ChannelWatermarks.Add(new ChannelWatermark
|
||||
@@ -55,7 +60,7 @@ public class WatermarkHandlerTests
|
||||
Id = id,
|
||||
Name = name,
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
ImageSource = ChannelWatermarkImageSource.Custom,
|
||||
ImageSource = imageSource,
|
||||
Image = "watermark.png",
|
||||
Location = WatermarkLocation.BottomRight,
|
||||
Size = WatermarkSize.Scaled,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static ErsatzTV.Application.Watermarks.Mapper;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Watermarks;
|
||||
|
||||
[TestFixture]
|
||||
public class WatermarkMapperTests
|
||||
{
|
||||
[Test]
|
||||
public void ProjectToResponseModel_Should_Carry_ImageSource()
|
||||
{
|
||||
var watermark = new ChannelWatermark
|
||||
{
|
||||
Id = 7,
|
||||
Name = "Channel Bug",
|
||||
ImageSource = ChannelWatermarkImageSource.ChannelLogo
|
||||
};
|
||||
|
||||
WatermarkResponseModel result = ProjectToResponseModel(watermark);
|
||||
|
||||
result.Id.ShouldBe(7);
|
||||
result.Name.ShouldBe("Channel Bug");
|
||||
result.ImageSource.ShouldBe(ChannelWatermarkImageSource.ChannelLogo);
|
||||
}
|
||||
}
|
||||
@@ -63,8 +63,8 @@ public class WatermarkControllerTests
|
||||
{
|
||||
List<WatermarkResponseModel> models =
|
||||
[
|
||||
new WatermarkResponseModel(1, "Corner Logo"),
|
||||
new WatermarkResponseModel(2, "Ticker")
|
||||
new WatermarkResponseModel(1, "Corner Logo", ChannelWatermarkImageSource.Custom),
|
||||
new WatermarkResponseModel(2, "Ticker", ChannelWatermarkImageSource.ChannelLogo)
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllWatermarksForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
[TestFixture]
|
||||
public class DbInitializerChannelBugWatermarkTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Initialize_Should_Seed_Channel_Bug_Watermark()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await DbInitializer.Initialize(context, CancellationToken.None);
|
||||
|
||||
ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug");
|
||||
watermark.Mode.ShouldBe(ChannelWatermarkMode.Permanent);
|
||||
watermark.ImageSource.ShouldBe(ChannelWatermarkImageSource.ChannelLogo);
|
||||
watermark.Image.ShouldBeNull();
|
||||
watermark.Location.ShouldBe(WatermarkLocation.TopLeft);
|
||||
watermark.Size.ShouldBe(WatermarkSize.Scaled);
|
||||
watermark.WidthPercent.ShouldBe(5.0);
|
||||
watermark.HorizontalMarginPercent.ShouldBe(1.0);
|
||||
watermark.VerticalMarginPercent.ShouldBe(1.0);
|
||||
watermark.Opacity.ShouldBe(80);
|
||||
watermark.ZIndex.ShouldBe(0);
|
||||
watermark.PlaceWithinSourceContent.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// The production instance already has a hand-made "Channel Bug" row with tuned geometry.
|
||||
// Seeding must adopt it untouched, never overwrite it and never duplicate it.
|
||||
[Test]
|
||||
public async Task Initialize_Should_Adopt_Existing_Channel_Bug_Watermark_Untouched()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await context.ChannelWatermarks.AddAsync(
|
||||
new ChannelWatermark
|
||||
{
|
||||
Name = "Channel Bug",
|
||||
Mode = ChannelWatermarkMode.Intermittent,
|
||||
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
|
||||
Location = WatermarkLocation.BottomRight,
|
||||
Size = WatermarkSize.Scaled,
|
||||
WidthPercent = 12,
|
||||
HorizontalMarginPercent = 3,
|
||||
VerticalMarginPercent = 4,
|
||||
Opacity = 55,
|
||||
FrequencyMinutes = 10,
|
||||
DurationSeconds = 20
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await DbInitializer.Initialize(context, CancellationToken.None);
|
||||
|
||||
ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug");
|
||||
watermark.Mode.ShouldBe(ChannelWatermarkMode.Intermittent);
|
||||
watermark.Location.ShouldBe(WatermarkLocation.BottomRight);
|
||||
watermark.WidthPercent.ShouldBe(12);
|
||||
watermark.Opacity.ShouldBe(55);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Initialize_Should_Be_Idempotent()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await DbInitializer.Initialize(context, CancellationToken.None);
|
||||
await DbInitializer.Initialize(context, CancellationToken.None);
|
||||
|
||||
context.ChannelWatermarks.Count(w => w.Name == "Channel Bug").ShouldBe(1);
|
||||
}
|
||||
|
||||
// No IsSystem flag exists on ChannelWatermark, and Initialize runs on every startup, so without
|
||||
// a seed marker a deliberate delete would be undone forever.
|
||||
[Test]
|
||||
public async Task Initialize_Should_Not_Resurrect_A_Deleted_Preset()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await DbInitializer.Initialize(context, CancellationToken.None);
|
||||
|
||||
ChannelWatermark seeded = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug");
|
||||
context.ChannelWatermarks.Remove(seeded);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await DbInitializer.Initialize(context, CancellationToken.None);
|
||||
|
||||
context.ChannelWatermarks.Any(w => w.Name == "Channel Bug").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// The production sequence is ADOPT (an existing hand-made row) and then, possibly, delete —
|
||||
// not seed-then-delete. The marker is written on the adopt path too, so this must not resurrect.
|
||||
[Test]
|
||||
public async Task Initialize_Should_Not_Resurrect_An_Adopted_Preset_After_Deletion()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await context.ChannelWatermarks.AddAsync(
|
||||
new ChannelWatermark
|
||||
{
|
||||
Name = "Channel Bug",
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
|
||||
Location = WatermarkLocation.BottomRight,
|
||||
Size = WatermarkSize.Scaled,
|
||||
WidthPercent = 12,
|
||||
Opacity = 55
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// First run adopts the existing row and records the marker.
|
||||
await DbInitializer.Initialize(context, CancellationToken.None);
|
||||
|
||||
ChannelWatermark adopted = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug");
|
||||
context.ChannelWatermarks.Remove(adopted);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await DbInitializer.Initialize(context, CancellationToken.None);
|
||||
|
||||
context.ChannelWatermarks.Any(w => w.Name == "Channel Bug").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Initialize_Should_Stamp_The_Preset_On_Freshly_Seeded_Templates()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await DbInitializer.Initialize(context, CancellationToken.None);
|
||||
|
||||
ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug");
|
||||
List<ChannelTemplate> templates = context.ChannelTemplates.Where(t => t.IsSystem).ToList();
|
||||
|
||||
// Assert the collection is non-empty FIRST: a bare foreach over zero rows passes vacuously,
|
||||
// so this test would stay green if template seeding silently bailed out.
|
||||
templates.Count.ShouldBe(2);
|
||||
foreach (ChannelTemplate template in templates)
|
||||
{
|
||||
template.WatermarkId.ShouldBe(watermark.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32656,7 +32656,8 @@
|
||||
"WatermarkResponseModel": {
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
"name",
|
||||
"imageSource"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32666,6 +32667,9 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"imageSource": {
|
||||
"$ref": "#/components/schemas/ChannelWatermarkImageSource"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -289,6 +289,10 @@ returns `null` when the channel has no logo so the SPA falls back to its generat
|
||||
raw un-rooted `{path, contentType}` form is still used only by the channel **editor** DTO
|
||||
(`ChannelDetailResponseModel.Logo`), which round-trips it back on save.
|
||||
|
||||
`GET /api/v1/watermarks` returns picker-grade rows that carry `imageSource` alongside `id`/`name`
|
||||
(#67), so a client can find the seeded logo-driven `Channel Bug` preset without matching its
|
||||
user-editable name. The full geometry still requires `GET /api/v1/watermarks/{id}`.
|
||||
|
||||
### 4a. Artwork content type is sniffed, never client-supplied (issue #283)
|
||||
|
||||
The uploaded-artwork surfaces (channel logo, watermark) must never trust a client-declared content
|
||||
|
||||
+31
-2
@@ -139,6 +139,35 @@ The scheduling engine (`ErsatzTV.Core/Scheduling/`) resolves schedule items into
|
||||
|
||||
## Watermarks
|
||||
|
||||
`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom upload, channel logo, or built-in resource. Positioned with percentage-based margins and z-index.
|
||||
`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom
|
||||
upload, channel logo, or built-in resource. Positioned with percentage-based margins and z-index.
|
||||
|
||||
Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo fetching — see issue #1 for details.
|
||||
A watermark is a **shared, named entity** (unique `Name`), referenced by playout items, schedule
|
||||
items, block items and decos; a channel points at one via `Channel.WatermarkId`. It is not
|
||||
per-channel state.
|
||||
|
||||
`DbInitializer` seeds one shared preset named **`Channel Bug`** (`ImageSource = ChannelLogo`,
|
||||
Permanent, TopLeft, Scaled 5% width, 1%/1% margins, 80% opacity). Because `ChannelLogo` resolves
|
||||
each channel's own `ArtworkKind.Logo` artwork at render time, this single row makes every channel
|
||||
that points at it use its own logo as its on-screen bug — one uploaded image drives both the guide
|
||||
listing and the bug (#67). An existing `Channel Bug` row is adopted untouched, never overwritten, and
|
||||
a `ConfigElement` marker (`watermark.channel_bug_seeded`) makes the seed run once per database, so a
|
||||
deliberately deleted preset is not resurrected on the next restart.
|
||||
|
||||
Quick-add channel creation defaults to the preset (if the preset has been deleted, creation falls
|
||||
back to no watermark rather than failing), and the channel editor's Branding tab exposes it as a
|
||||
"Use logo as on-screen bug" toggle. The default applies to **newly created** channels — an existing
|
||||
channel with no watermark does not gain one retroactively when a logo is uploaded; flip the toggle.
|
||||
On a **fresh** install the seed also stamps the preset onto
|
||||
the system channel templates it creates, so the library-to-lineup builder (which inherits
|
||||
`WatermarkId` from the selected template) gets the default too. On an existing install the templates
|
||||
are left alone, so builder-created and auto-tuned channels there inherit whatever the template
|
||||
already specifies.
|
||||
|
||||
**Limitation:** a logo set via **External logo URL** cannot drive the bug. `WatermarkSelector`
|
||||
resolves it to the URL and then `File.Exists`-checks it, which is never true, so the watermark is
|
||||
silently dropped — the URL wins for the guide listing but disables the on-screen bug. Tracked as
|
||||
**#502**; the editor does not offer a bug preview in that case.
|
||||
|
||||
Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo
|
||||
fetching — see issue #1 for details.
|
||||
|
||||
@@ -2361,3 +2361,43 @@ fixed here:
|
||||
sweep. This is *why* it cannot cross-delete in a mixed library, but it also means a music video removed
|
||||
from Jellyfin is never removed from ErsatzTV. Pre-existing and orthogonal to this change; it belongs with
|
||||
that scanner's other gaps (no `ItemId`/`Etag`, path-keyed identity — see #488).
|
||||
|
||||
## 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)
|
||||
|
||||
#67 asked that one uploaded image drive both the listing logo and the on-screen bug, separably
|
||||
overridable, with preview. Most of it already existed: `ChannelWatermarkImageSource.ChannelLogo`
|
||||
resolves the channel's own logo artwork at render time at all three watermark precedence levels, and
|
||||
`Custom` already provides the independent override. Production had already been running exactly this
|
||||
pattern by hand — 43 channels pointing at one hand-made `Channel Bug` preset.
|
||||
|
||||
**Decision: seed that preset rather than add per-channel bug columns.** A watermark is a shared named
|
||||
entity, so per-channel geometry would need either a dual-provider migration or one watermark row per
|
||||
channel (under a unique-name index). Since `ChannelLogo` resolves per channel at render time, a single
|
||||
shared row already delivers the user-visible behavior with no schema change.
|
||||
|
||||
- The seed **adopts** an existing `Channel Bug` row untouched, so an operator's tuned geometry is never
|
||||
overwritten, and a `ConfigElement` marker (`watermark.channel_bug_seeded`) makes it run once per
|
||||
database rather than once per name-absence — `ChannelWatermark` has no `IsSystem` flag and
|
||||
`DbInitializer.Initialize` runs at every startup, so a name-only guard would resurrect a deliberately
|
||||
deleted preset forever. Covered by `DbInitializerChannelBugWatermarkTests`.
|
||||
- `WatermarkResponseModel` gained `imageSource` (additive under the frozen `/api/v1`, #286) so clients
|
||||
identify logo-driven presets generically instead of matching a user-editable name. Note the limit:
|
||||
once a *second* logo-driven preset exists, `imageSource` identifies the class but not *the* default,
|
||||
so `findLogoBugWatermark` prefers the seeded name as a deterministic tiebreak.
|
||||
- The default is applied by the SPA's quick-add **creation** path, not by inferring "newness" in the
|
||||
editor — quick-add creates through the API and then navigates to the editor, so the editor only ever
|
||||
loads an existing row. The editor toggle reflects the **referenced** watermark's `imageSource`; an
|
||||
earlier draft searched the list instead, which (because `getWatermarks()` sorts by name) would have
|
||||
silently repointed channels bound to a non-first logo-driven preset. Caught in independent review
|
||||
and pinned by a regression test.
|
||||
- The builder flow is covered on **fresh installs only**, by stamping the preset onto the system
|
||||
channel templates the seed itself creates; existing installs' templates are never mutated.
|
||||
- Server-side create defaulting was rejected: making an omitted `watermarkId` mean "give me a
|
||||
watermark" would surprise machine clients of the frozen API. `POST /api/v1/channels/auto-tune` is
|
||||
therefore unchanged.
|
||||
- **Not fixed here:** external-URL logos never render a bug (`WatermarkSelector` `File.Exists`-checks a
|
||||
URL). Pre-existing, lands in the FFmpeg render path, tracked as **#502**. This change only stops the
|
||||
preview from promising it.
|
||||
|
||||
**Accepted trade-off:** every channel on the shared preset shares one geometry; per-channel tweaks mean
|
||||
creating a second preset on the Watermarks screen.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
# Unified logo / on-screen bug — design (ersatztv#67)
|
||||
|
||||
Date: 2026-07-20
|
||||
Issue: [#67](http://192.168.1.95:3000/timothy/ersatztv/issues/67) (child of the #62 "Create Channel
|
||||
backend prerequisites" epic)
|
||||
Status: design approved, ready for implementation planning
|
||||
|
||||
## 1. What the issue asked for vs. what already exists
|
||||
|
||||
#67 asks that a single uploaded image drive **both** the listing logo and the on-screen bug
|
||||
(watermark) by default, with the option to set them separately, plus live preview for both uses.
|
||||
|
||||
Recon showed most of that capability is already built:
|
||||
|
||||
| #67 requirement | State before this work |
|
||||
|---|---|
|
||||
| One image drives logo + bug | **Exists.** `ChannelWatermarkImageSource.ChannelLogo` (`ErsatzTV.Core/Domain/ChannelWatermark.cs:48`) resolves the channel's `ArtworkKind.Logo` artwork at render time, at all three watermark precedence levels (`ErsatzTV.Core/FFmpeg/WatermarkSelector.cs:216-236,265-285,314-334,384-392`). Exposed as `WatermarkFullResponseModel.ImageSource`, selectable in `web/src/screens/WatermarksScreen.tsx:30-33`. |
|
||||
| Override either independently | **Exists.** `ImageSource = Custom` + a watermark-target upload. |
|
||||
| ...by **default** | **Missing.** New channels are created with `watermarkId: null` (`web/src/screens/ChannelsScreen.tsx:377`); nothing is logo-driven unless a watermark is hand-built and hand-assigned. |
|
||||
| Live preview for both uses | **Missing.** `WatermarksScreen.tsx:534-539` renders only a raw `<img>` of the watermark file. Nothing shows the resolved geometry (location / size / margins / opacity), which is the part users actually get wrong. |
|
||||
|
||||
**Field validation.** The production instance (jazz, 192.168.1.29) already runs this pattern by hand:
|
||||
all 43 channels carry `ArtworkKind.Logo` artwork and point at a single shared watermark named
|
||||
`Channel Bug` with `ImageSource = ChannelLogo` and `Image = NULL`. This design therefore does not
|
||||
invent a mechanism — it productizes the one the operator already proved, so that a new channel gets
|
||||
it without hand-wiring, and so the geometry is visible before saving.
|
||||
|
||||
Observed prod geometry, adopted as the shipped default:
|
||||
|
||||
```
|
||||
Name="Channel Bug" Mode=Permanent(1) ImageSource=ChannelLogo(1) Image=NULL
|
||||
Location=TopLeft(3) Size=Scaled(0) WidthPercent=5.0
|
||||
HorizontalMarginPercent=1.0 VerticalMarginPercent=1.0
|
||||
FrequencyMinutes=0 DurationSeconds=0 Opacity=80
|
||||
PlaceWithinSourceContent=0 ZIndex=0
|
||||
```
|
||||
|
||||
## 2. Structural constraints that shaped the design
|
||||
|
||||
1. **A watermark is a shared, named entity**, not per-channel state: unique `Name`
|
||||
(`ErsatzTV.Infrastructure/Data/Configurations/ChannelWatermarkConfiguration.cs:11-18`), referenced
|
||||
by playout items, schedule items, block items and decos (`ChannelWatermark.cs:24-31`), with
|
||||
`Channel.WatermarkId` pointing at one (`Channel.cs:21-22`). Per-channel bug *settings* have
|
||||
nowhere to live without either new `Channel` columns or one watermark row per channel.
|
||||
2. **`ImageSource = ChannelLogo` resolves per-channel at render time.** One shared preset therefore
|
||||
already makes every channel use *its own* logo. This is why no schema change is needed.
|
||||
3. **Logo is not a `Channel` field** — it is an `Artwork` row (`ArtworkKind.Logo = 2`) whose `Path`
|
||||
holds an image-cache hash or an absolute external URL, served at `/iptv/logos/{hash}`
|
||||
(`ErsatzTV/Controllers/IptvController.cs:282-293`).
|
||||
4. **`/api/v1` is frozen-additive** (`docs/decisions.md`, #286): fields may be added, never removed
|
||||
or reshaped.
|
||||
|
||||
### Alternatives rejected
|
||||
|
||||
- **Per-channel bug columns on `Channel`** — full fidelity to "separably overridable", but requires a
|
||||
dual-provider migration (`scripts/add-migration.sh`) and changes to the watermark precedence chain.
|
||||
Rejected as disproportionate: the shared preset already delivers the user-visible behavior.
|
||||
- **Auto-create a watermark per channel** — gives per-channel geometry with no migration, but
|
||||
proliferates rows under a unique-name index and fills the shared Watermarks screen with one row per
|
||||
channel.
|
||||
|
||||
Accepted trade-off: every channel on the shared preset shares one geometry. Per-channel tweaks mean
|
||||
creating a second preset — which the existing Watermarks screen already supports.
|
||||
|
||||
## 3. Backend
|
||||
|
||||
`DbInitializer.SeedChannelBugWatermark(context, cancellationToken)`, invoked from `Initialize`
|
||||
**before** the existing `SeedChannelTemplates` call (`ErsatzTV.Infrastructure/Data/DbInitializer.cs:134`)
|
||||
so the seeded watermark's id is available to the template seed. It returns `int?` — the preset's id.
|
||||
|
||||
- If a `ChannelWatermark` named `Channel Bug` already exists → **adopt it untouched**, never
|
||||
duplicated and never overwritten. An operator's tuned geometry (as on prod) survives.
|
||||
- Otherwise insert one row with the geometry in §1.
|
||||
- **Seed-once marker.** `ChannelWatermark` has no `IsSystem` flag (unlike `ChannelTemplate`), so a
|
||||
pure name-guard would resurrect the row on every restart after a deliberate delete or rename. A
|
||||
`ConfigElement` marker (`ConfigElementKey.WatermarkChannelBugSeeded`, key
|
||||
`watermark.channel_bug_seeded`) is written on first seed **or adopt**; once present, the seed is a
|
||||
no-op. This follows the file's own `EnsureDefaultChannelTemplateConfig` precedent (`:210-231`).
|
||||
A deliberate delete stays deleted.
|
||||
|
||||
`SeedChannelTemplates` takes the returned id and stamps it as `WatermarkId` on the **newly seeded**
|
||||
system templates (`ChannelTemplate.WatermarkId`, `ErsatzTV.Core/Domain/ChannelTemplate.cs:14`). This
|
||||
only ever touches rows the seed is creating for the first time — the existing name-guard means an
|
||||
install that already has `Standard` / `Music videos` is untouched, so no operator's template is
|
||||
mutated.
|
||||
|
||||
No schema change. No `WatermarkSelector` change. No change to how watermarks render.
|
||||
|
||||
**Additive DTO change**: `WatermarkResponseModel` (the `GET /api/v1/watermarks` picker list) gains
|
||||
`imageSource`, so the SPA can identify logo-driven presets generically instead of matching a
|
||||
user-editable name. Legal under frozen-additive `/api/v1`; also useful to MCP/machine clients.
|
||||
|
||||
## 4. Frontend
|
||||
|
||||
- **`<BugPreview>`** — new shared presentational component: a 16:9 frame with the image absolutely
|
||||
positioned from `location` (9 values, `ErsatzTV.FFmpeg/State/WatermarkState.cs`), `size`,
|
||||
`widthPercent`, horizontal/vertical margin percents and `opacity`. No data fetching.
|
||||
- **Where the default is applied — at creation, not by inferring "newness" in the editor.** Quick-add
|
||||
creates the channel through the API and *then* navigates to `/app/edit-channel/{id}`
|
||||
(`docs/decisions.md:445-456`), so the editor only ever loads an already-persisted row and cannot
|
||||
reliably tell "new" from "existing". The default is therefore applied at the SPA's quick-add
|
||||
creation path — `ChannelsScreen.tsx:377`, which currently hardcodes `watermarkId: null` — by
|
||||
setting `watermarkId` to the seeded preset at create time.
|
||||
- **The `ChannelBuilder` lineup flow is covered via template stamping** (revised after independent
|
||||
review). It does not hardcode a watermark: it *inherits* `watermarkId` from the selected
|
||||
`ChannelTemplate` (`web/src/builder/ChannelBuilder.tsx:1025`, `:2046`). Because §3's seed stamps the
|
||||
preset onto templates **it is creating for the first time**, a fresh install gets the default through
|
||||
both creation flows with no user data mutated. On an existing install (including production) the
|
||||
template name-guard means nothing is stamped, so builder-created channels there keep inheriting
|
||||
whatever the template already says — a user who wants it sets the watermark on the template.
|
||||
`POST /api/v1/channels/auto-tune` is unchanged either way.
|
||||
- **`ChannelEditScreen`** (Branding tab, `:55`): a **"Use logo as on-screen bug"** toggle. Loading and
|
||||
re-saving an existing channel never changes its watermark.
|
||||
- **Reflect from the referenced row, not from a search.** Ticked iff the watermark
|
||||
`draft.watermarkId` *points at* has `imageSource === 'ChannelLogo'`. An earlier draft searched the
|
||||
list for the first `ChannelLogo` preset; because `getWatermarks()` sorts by name
|
||||
(`web/src/api/pickers.ts:14`), that silently repointed a channel to the alphabetically-first
|
||||
logo-driven preset the moment a second one existed — which this design's own trade-off invites
|
||||
users to create. Caught in independent review.
|
||||
- **Tick-on target is deterministic**: prefer the preset named `Channel Bug`, else the first
|
||||
`ChannelLogo` preset. With multiple logo-driven presets, `imageSource` alone cannot identify *the*
|
||||
default, so a name preference is unavoidable here; it is a tiebreak, not the identification
|
||||
mechanism.
|
||||
- **The preview shows the referenced preset's real geometry**, fetched via the existing
|
||||
`GET /api/v1/watermarks/{id}` when the toggle is on. Hardcoding the seeded defaults would show
|
||||
the wrong position and size for exactly the users who tuned theirs — the case this feature exists
|
||||
to serve.
|
||||
- **External-URL logos get no bug preview.** `WatermarkSelector` resolves an external-URL logo to
|
||||
the URL itself and then calls `File.Exists` on it (`ErsatzTV.Core/FFmpeg/WatermarkSelector.cs:269-286`),
|
||||
which is never true, so the bug is silently dropped. The preview must not promise a bug that will
|
||||
never render; the toggle's help text and `docs/channels.md` say so. The underlying defect is
|
||||
pre-existing and tracked in its own issue, not fixed here.
|
||||
- **Batch auto-tune (`POST /api/v1/channels/auto-tune`) is left alone** — it is a server-side create,
|
||||
and the rejected "server-side default on create" option is what would have changed it.
|
||||
- **`WatermarksScreen`**: the raw `<img>` at `:534-539` is replaced by `<BugPreview>`.
|
||||
|
||||
Existing logo-upload and external-URL-precedence behavior (`docs/decisions.md:457-466`) is unchanged.
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- **NUnit**, mirroring `ErsatzTV.Tests/Infrastructure/DbInitializerChannelTemplateTests.cs`:
|
||||
seeds the preset when absent; **adopts an existing same-name row without duplicating or mutating
|
||||
it** (the production case — this test is what protects the operator's tuned geometry); is
|
||||
idempotent across repeated `Initialize` calls; **does not resurrect a deleted preset** once the
|
||||
seed marker exists; stamps `WatermarkId` on freshly seeded templates only.
|
||||
- **Vitest**: `BugPreview` geometry mapping across all 9 locations, plus opacity/size/margins. For the
|
||||
toggle:
|
||||
- **loading and re-saving an existing channel without touching the toggle leaves `watermarkId`
|
||||
byte-identical** — the guarantee protecting the 43 production channels, and the test the first
|
||||
draft of this plan promised but omitted;
|
||||
- with **two** `ChannelLogo` presets, a channel pointing at the non-first one still reads as ticked
|
||||
and is not repointed (the review's blocker, pinned by a regression test);
|
||||
- the creation path stamps the preset, and **degrades to `null` rather than aborting** when the
|
||||
watermark lookup fails.
|
||||
|
||||
## 6. Docs to update in the same PR
|
||||
|
||||
Per `CLAUDE.md`'s docs-update rule:
|
||||
|
||||
- `docs/api-conventions.md` checklist + regenerate `v1.json` and `endpoint-index.md` via
|
||||
`./scripts/update-openapi.sh`, then `npm run generate:api` (the `imageSource` addition).
|
||||
- `docs/channels.md` — watermark section: the seeded `Channel Bug` preset and the logo-drives-bug default.
|
||||
- `docs/decisions.md` — the shared-preset-over-migration decision and its accepted trade-off.
|
||||
- `docs/spa-conventions.md` — only if `<BugPreview>` establishes a new shared-component convention.
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
- Any dual-provider migration.
|
||||
- **Fixing the external-URL-logo defect.** `WatermarkSelector.cs:269-286` resolves an external-URL
|
||||
logo to the URL and then `File.Exists`-checks it, so such channels never render a bug even though
|
||||
the editor advertises the URL as winning. Pre-existing, unrelated to this change, and a fix lands in
|
||||
the FFmpeg render path — a different risk class than this seed-plus-SPA PR. Tracked as **#502**;
|
||||
this PR only stops the preview from promising it.
|
||||
- The dead generated-initials FFmpeg fallback: `WatermarkSelector.cs:230-236` runs `File.Exists`
|
||||
against a URL produced by `ChannelLogoGenerator.GenerateChannelLogoUrl`, which hardcodes localhost
|
||||
(`ChannelLogoGenerator.cs:93-94`, issue #1). Deliberately deferred in `docs/decisions.md:1617-1623`.
|
||||
- The initials shown at `/app/channels` on production: **not a defect.** That is fixed on `main` by
|
||||
`1aa1f0cf` ("fix(464): render real channel logos in guide grid + channels list", 2026-07-19 13:28),
|
||||
which postdates the deployed image (built 2026-07-18 22:38, i.e. tag `v26.10.0`). It resolves on
|
||||
the pending v26.11.0 deploy — the same deploy #350 and #474 are waiting on.
|
||||
Vendored
+1
@@ -1872,6 +1872,7 @@ export interface components {
|
||||
"WatermarkResponseModel": {
|
||||
"id": number;
|
||||
"name": string;
|
||||
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
|
||||
};
|
||||
"WatermarkSize": "Scaled" | "ActualSize";
|
||||
"XmltvBlockBehavior": "SplitTimeEvenly" | "UseActualTimes";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createWatermark,
|
||||
deleteWatermark,
|
||||
findLogoBugWatermark,
|
||||
getWatermark,
|
||||
updateWatermark,
|
||||
watermarkImageUrl,
|
||||
@@ -84,3 +85,33 @@ describe('watermarks api client', () => {
|
||||
expect(watermarkImageUrl('abc.png')).toBe('/artwork/watermarks/abc.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLogoBugWatermark', () => {
|
||||
it('prefers the row named "Channel Bug" over an earlier ChannelLogo row', () => {
|
||||
const watermarks = [
|
||||
{ id: 4, imageSource: 'ChannelLogo' as const, name: 'Alpha Bug' },
|
||||
{ id: 9, imageSource: 'ChannelLogo' as const, name: 'Channel Bug' }
|
||||
];
|
||||
|
||||
expect(findLogoBugWatermark(watermarks)).toMatchObject({ id: 9 });
|
||||
});
|
||||
|
||||
it('falls back to the first ChannelLogo row when no "Channel Bug" exists', () => {
|
||||
const watermarks = [
|
||||
{ id: 2, imageSource: 'Custom' as const, name: 'Corner bug' },
|
||||
{ id: 4, imageSource: 'ChannelLogo' as const, name: 'Alpha Bug' },
|
||||
{ id: 6, imageSource: 'ChannelLogo' as const, name: 'Beta Bug' }
|
||||
];
|
||||
|
||||
expect(findLogoBugWatermark(watermarks)).toMatchObject({ id: 4 });
|
||||
});
|
||||
|
||||
it('returns null when none are ChannelLogo', () => {
|
||||
const watermarks = [
|
||||
{ id: 2, imageSource: 'Custom' as const, name: 'Corner bug' },
|
||||
{ id: 3, imageSource: 'Resource' as const, name: 'Station ident' }
|
||||
];
|
||||
|
||||
expect(findLogoBugWatermark(watermarks)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,16 @@ export function getWatermark(id: number): Promise<WatermarkDetail> {
|
||||
return request<WatermarkDetail>(`/api/v1/watermarks/${id}`);
|
||||
}
|
||||
|
||||
// Identifies THE default logo-driven preset. imageSource alone is not enough once a second
|
||||
// ChannelLogo preset exists (the design invites creating one for per-channel geometry), and
|
||||
// getWatermarks() sorts by name — so prefer the seeded name, then fall back deterministically.
|
||||
export function findLogoBugWatermark<
|
||||
T extends { id: number; imageSource: components['schemas']['ChannelWatermarkImageSource']; name: null | string }
|
||||
>(watermarks: T[]): T | null {
|
||||
const logoDriven = watermarks.filter((watermark) => watermark.imageSource === 'ChannelLogo');
|
||||
return logoDriven.find((watermark) => watermark.name === 'Channel Bug') ?? logoDriven[0] ?? null;
|
||||
}
|
||||
|
||||
export function createWatermark(body: CreateWatermarkRequest): Promise<WatermarkDetail> {
|
||||
return request<WatermarkDetail>('/api/v1/watermarks', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { BugPreview, bugPreviewStyle, type BugPreviewGeometry } from './bugPreview';
|
||||
|
||||
const base: BugPreviewGeometry = {
|
||||
horizontalMargin: 1,
|
||||
location: 'TopLeft',
|
||||
opacity: 80,
|
||||
size: 'Scaled',
|
||||
verticalMargin: 1,
|
||||
width: 5
|
||||
};
|
||||
|
||||
describe('bugPreviewStyle', () => {
|
||||
it('anchors TopLeft using the margins', () => {
|
||||
const style = bugPreviewStyle(base);
|
||||
expect(style.top).toBe('1%');
|
||||
expect(style.left).toBe('1%');
|
||||
expect(style.bottom).toBeUndefined();
|
||||
expect(style.right).toBeUndefined();
|
||||
});
|
||||
|
||||
it('anchors BottomRight using the margins', () => {
|
||||
const style = bugPreviewStyle({ ...base, location: 'BottomRight' });
|
||||
expect(style.bottom).toBe('1%');
|
||||
expect(style.right).toBe('1%');
|
||||
expect(style.top).toBeUndefined();
|
||||
expect(style.left).toBeUndefined();
|
||||
});
|
||||
|
||||
it('centers horizontally for TopMiddle', () => {
|
||||
const style = bugPreviewStyle({ ...base, location: 'TopMiddle' });
|
||||
expect(style.top).toBe('1%');
|
||||
expect(style.left).toBe('50%');
|
||||
expect(style.transform).toBe('translateX(-50%)');
|
||||
});
|
||||
|
||||
it('centers vertically for LeftMiddle', () => {
|
||||
const style = bugPreviewStyle({ ...base, location: 'LeftMiddle' });
|
||||
expect(style.left).toBe('1%');
|
||||
expect(style.top).toBe('50%');
|
||||
expect(style.transform).toBe('translateY(-50%)');
|
||||
});
|
||||
|
||||
it('centers both axes for MiddleCenter', () => {
|
||||
const style = bugPreviewStyle({ ...base, location: 'MiddleCenter' });
|
||||
expect(style.transform).toBe('translate(-50%, -50%)');
|
||||
});
|
||||
|
||||
it('anchors BottomLeft using the margins', () => {
|
||||
const style = bugPreviewStyle({ ...base, location: 'BottomLeft' });
|
||||
expect(style.bottom).toBe('1%');
|
||||
expect(style.left).toBe('1%');
|
||||
expect(style.top).toBeUndefined();
|
||||
expect(style.right).toBeUndefined();
|
||||
expect(style.transform).toBeUndefined();
|
||||
});
|
||||
|
||||
it('anchors TopRight using the margins', () => {
|
||||
const style = bugPreviewStyle({ ...base, location: 'TopRight' });
|
||||
expect(style.top).toBe('1%');
|
||||
expect(style.right).toBe('1%');
|
||||
expect(style.bottom).toBeUndefined();
|
||||
expect(style.left).toBeUndefined();
|
||||
expect(style.transform).toBeUndefined();
|
||||
});
|
||||
|
||||
it('centers vertically for RightMiddle', () => {
|
||||
const style = bugPreviewStyle({ ...base, location: 'RightMiddle' });
|
||||
expect(style.right).toBe('1%');
|
||||
expect(style.top).toBe('50%');
|
||||
expect(style.transform).toBe('translateY(-50%)');
|
||||
});
|
||||
|
||||
it('centers horizontally for BottomMiddle', () => {
|
||||
const style = bugPreviewStyle({ ...base, location: 'BottomMiddle' });
|
||||
expect(style.bottom).toBe('1%');
|
||||
expect(style.left).toBe('50%');
|
||||
expect(style.transform).toBe('translateX(-50%)');
|
||||
});
|
||||
|
||||
it('scales width by percent when size is Scaled', () => {
|
||||
expect(bugPreviewStyle({ ...base, width: 12 }).width).toBe('12%');
|
||||
});
|
||||
|
||||
it('leaves width unset at ActualSize', () => {
|
||||
expect(bugPreviewStyle({ ...base, size: 'ActualSize' }).width).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps opacity percent to a 0-1 fraction', () => {
|
||||
expect(bugPreviewStyle({ ...base, opacity: 80 }).opacity).toBeCloseTo(0.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BugPreview', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('renders the image when a src is supplied', () => {
|
||||
render(<BugPreview {...base} alt="Bug preview" src="/iptv/logos/abc" />);
|
||||
expect(screen.getByAltText('Bug preview')).toHaveAttribute('src', '/iptv/logos/abc');
|
||||
});
|
||||
|
||||
it('renders an empty frame without a src', () => {
|
||||
render(<BugPreview {...base} src={null} />);
|
||||
expect(screen.queryByRole('img')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { components } from '../api/generated/v1';
|
||||
|
||||
// Renders where the on-screen bug lands, using the same percent-of-frame geometry the FFmpeg
|
||||
// watermark filters use (WatermarkScaleFilter scales to widthPercent of output width; margins are
|
||||
// percentages). This is an approximation for editing feedback, not a frame-accurate render.
|
||||
/* eslint-disable react-refresh/only-export-components -- geometry math must stay colocated and pure for unit testing without the DOM */
|
||||
export interface BugPreviewGeometry {
|
||||
horizontalMargin: number;
|
||||
location: components['schemas']['WatermarkLocation'];
|
||||
opacity: number;
|
||||
size: components['schemas']['WatermarkSize'];
|
||||
verticalMargin: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function bugPreviewStyle(geometry: BugPreviewGeometry): CSSProperties {
|
||||
const horizontal = `${geometry.horizontalMargin}%`;
|
||||
const vertical = `${geometry.verticalMargin}%`;
|
||||
|
||||
const placement: CSSProperties = (() => {
|
||||
switch (geometry.location) {
|
||||
case 'BottomLeft':
|
||||
return { bottom: vertical, left: horizontal };
|
||||
case 'BottomMiddle':
|
||||
return { bottom: vertical, left: '50%', transform: 'translateX(-50%)' };
|
||||
case 'BottomRight':
|
||||
return { bottom: vertical, right: horizontal };
|
||||
case 'LeftMiddle':
|
||||
return { left: horizontal, top: '50%', transform: 'translateY(-50%)' };
|
||||
// NOTE: FFmpeg currently renders MiddleCenter bottom-right (OverlayWatermarkFilter has no
|
||||
// arm for it, so it hits the bottom-right default) — see #503. We keep the semantically
|
||||
// correct centered preview here rather than encoding that defect.
|
||||
case 'MiddleCenter':
|
||||
return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' };
|
||||
case 'RightMiddle':
|
||||
return { right: horizontal, top: '50%', transform: 'translateY(-50%)' };
|
||||
case 'TopLeft':
|
||||
return { left: horizontal, top: vertical };
|
||||
case 'TopMiddle':
|
||||
return { left: '50%', top: vertical, transform: 'translateX(-50%)' };
|
||||
case 'TopRight':
|
||||
return { right: horizontal, top: vertical };
|
||||
default:
|
||||
return { left: horizontal, top: vertical };
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
...placement,
|
||||
objectFit: 'contain',
|
||||
opacity: geometry.opacity / 100,
|
||||
position: 'absolute',
|
||||
width: geometry.size === 'Scaled' ? `${geometry.width}%` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function BugPreview({
|
||||
alt = 'On-screen bug preview',
|
||||
src,
|
||||
...geometry
|
||||
}: BugPreviewGeometry & { alt?: string; src: null | string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: '16 / 9',
|
||||
background: 'var(--ctv-surface-sunken, #1b1b1f)',
|
||||
border: '1px solid var(--ctv-border, #33343a)',
|
||||
borderRadius: 6,
|
||||
maxWidth: 320,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{src && <img alt={alt} src={src} style={bugPreviewStyle(geometry)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -232,6 +232,10 @@ export interface SwitchProps {
|
||||
checked?: boolean;
|
||||
onChange?: (next: boolean) => void;
|
||||
label?: string;
|
||||
// When true, `label` still becomes the accessible name (aria-label) but is not rendered as
|
||||
// visible text — for callers that already show the same caption elsewhere (e.g. a settings
|
||||
// Row label) and would otherwise duplicate it.
|
||||
hideLabel?: boolean;
|
||||
disabled?: boolean;
|
||||
size?: ControlSize;
|
||||
style?: CSSProperties;
|
||||
@@ -241,6 +245,7 @@ export function Switch({
|
||||
checked = false,
|
||||
onChange,
|
||||
label,
|
||||
hideLabel = false,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
style
|
||||
@@ -269,7 +274,7 @@ export function Switch({
|
||||
>
|
||||
<span className="ctv-switch-knob" />
|
||||
</button>
|
||||
{label && <span>{label}</span>}
|
||||
{label && !hideLabel && <span>{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './components.css';
|
||||
|
||||
export * from './bugPreview';
|
||||
export * from './dataDisplay';
|
||||
export * from './feedback';
|
||||
export * from './forms';
|
||||
|
||||
@@ -54,9 +54,16 @@ interface FetchOptions {
|
||||
channelStatus?: number;
|
||||
onPut?: (body: unknown) => void;
|
||||
putResponseOverrides?: Record<string, unknown>;
|
||||
watermarks?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function mockApi({ channelOverrides = {}, channelStatus = 200, onPut, putResponseOverrides = {} }: FetchOptions = {}) {
|
||||
function mockApi({
|
||||
channelOverrides = {},
|
||||
channelStatus = 200,
|
||||
onPut,
|
||||
putResponseOverrides = {},
|
||||
watermarks = [{ id: 2, name: 'Corner bug', imageSource: 'Custom' }]
|
||||
}: FetchOptions = {}) {
|
||||
const loadedChannel = { ...channel, ...channelOverrides };
|
||||
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
|
||||
@@ -79,7 +86,25 @@ function mockApi({ channelOverrides = {}, channelStatus = 200, onPut, putRespons
|
||||
}
|
||||
|
||||
if (url === '/api/v1/watermarks') {
|
||||
return Promise.resolve(json([{ id: 2, name: 'Corner bug' }]));
|
||||
return Promise.resolve(json(watermarks));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/v1/watermarks/')) {
|
||||
const id = Number(url.slice('/api/v1/watermarks/'.length));
|
||||
const found = watermarks.find((watermark) => watermark.id === id);
|
||||
return found
|
||||
? Promise.resolve(
|
||||
json({
|
||||
horizontalMargin: 1,
|
||||
location: 'TopLeft',
|
||||
opacity: 80,
|
||||
size: 'Scaled',
|
||||
verticalMargin: 1,
|
||||
width: 5,
|
||||
...found
|
||||
})
|
||||
)
|
||||
: Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
|
||||
}
|
||||
|
||||
if (url === '/api/v1/filler-presets') {
|
||||
@@ -335,4 +360,220 @@ describe('ChannelEditScreen', () => {
|
||||
expect(await screen.findByDisplayValue('legacy-selector.py')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('use logo as on-screen bug toggle', () => {
|
||||
it('ticks the logo-bug toggle when the channel uses a ChannelLogo watermark', async () => {
|
||||
mockApi({
|
||||
channelOverrides: { watermarkId: 9 },
|
||||
watermarks: [
|
||||
{ id: 2, imageSource: 'Custom', name: 'Corner bug' },
|
||||
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
|
||||
]
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
expect(await screen.findByRole('switch', { name: 'Use logo as on-screen bug' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears watermarkId when the logo-bug toggle is switched off', async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({
|
||||
channelOverrides: { watermarkId: 9 },
|
||||
onPut: (body) => puts.push(body),
|
||||
watermarks: [
|
||||
{ id: 2, imageSource: 'Custom', name: 'Corner bug' },
|
||||
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
|
||||
]
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', { name: 'Use logo as on-screen bug' }));
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBeNull();
|
||||
});
|
||||
|
||||
// Regression: a search-by-imageSource implementation binds to the alphabetically-first
|
||||
// ChannelLogo preset (getWatermarks sorts by name), silently repointing this channel.
|
||||
it('reflects a non-first ChannelLogo preset without repointing the channel', async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({
|
||||
channelOverrides: { watermarkId: 9 },
|
||||
onPut: (body) => puts.push(body),
|
||||
// 'Alpha Bug' sorts before 'Channel Bug', so a search-by-imageSource would bind to id 4.
|
||||
watermarks: [
|
||||
{ id: 4, imageSource: 'ChannelLogo', name: 'Alpha Bug' },
|
||||
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
|
||||
]
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
expect(await screen.findByRole('switch', { name: 'Use logo as on-screen bug' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
|
||||
// Change something unrelated so the form is dirty and Save is enabled — the toggle itself
|
||||
// must NOT have touched watermarkId.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^General/ }));
|
||||
fireEvent.change(screen.getByDisplayValue('Cartoons'), { target: { value: 'Renamed channel' } });
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBe(9);
|
||||
});
|
||||
|
||||
// The guarantee protecting the existing production channels.
|
||||
it('leaves watermarkId unchanged when an untouched channel is re-saved', async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({
|
||||
channelOverrides: { watermarkId: 2 },
|
||||
onPut: (body) => puts.push(body),
|
||||
watermarks: [
|
||||
{ id: 2, imageSource: 'Custom', name: 'Sponsor bug' },
|
||||
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
|
||||
]
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Use logo as on-screen bug' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false'
|
||||
);
|
||||
|
||||
// Change something unrelated so the form is dirty and Save is enabled.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^General/ }));
|
||||
fireEvent.change(screen.getByDisplayValue('Cartoons'), { target: { value: 'Renamed channel' } });
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBe(2);
|
||||
});
|
||||
|
||||
// Regression: toggling off (watermarkId -> null) then back on used to always rebind to
|
||||
// logoBugTarget (the 'Channel Bug' default), silently losing a channel's own per-geometry
|
||||
// ChannelLogo preset.
|
||||
it('restores the channel’s own ChannelLogo preset — not the default — when re-toggled on', async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({
|
||||
channelOverrides: { watermarkId: 4 },
|
||||
onPut: (body) => puts.push(body),
|
||||
watermarks: [
|
||||
{ id: 4, imageSource: 'ChannelLogo', name: 'Alpha Bug' },
|
||||
{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
|
||||
]
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
const toggle = await screen.findByRole('switch', { name: 'Use logo as on-screen bug' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// watermarkId round-trips back to its original value across the off/on toggle, so the form
|
||||
// isn't dirty from that alone — touch something unrelated to enable Save.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^General/ }));
|
||||
fireEvent.change(screen.getByDisplayValue('Cartoons'), { target: { value: 'Renamed channel' } });
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
expect((puts[0] as { watermarkId: null | number }).watermarkId).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on-screen bug preview', () => {
|
||||
it('renders the preview image using the fetched watermark geometry', async () => {
|
||||
mockApi({
|
||||
channelOverrides: {
|
||||
logo: { path: 'iptv/logos/cartoons.png', contentType: 'image/png' },
|
||||
watermarkId: 9
|
||||
},
|
||||
watermarks: [
|
||||
{ id: 2, imageSource: 'Custom', name: 'Corner bug' },
|
||||
{
|
||||
id: 9,
|
||||
imageSource: 'ChannelLogo',
|
||||
name: 'Channel Bug',
|
||||
horizontalMargin: 3,
|
||||
location: 'BottomLeft',
|
||||
opacity: 60,
|
||||
size: 'Scaled',
|
||||
verticalMargin: 7,
|
||||
width: 11
|
||||
}
|
||||
]
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
const preview = await screen.findByAltText('On-screen bug preview');
|
||||
// Asymmetric values so a horizontal/vertical (or margin/width) prop swap fails this test.
|
||||
expect(preview).toHaveAttribute('src', 'iptv/logos/cartoons.png');
|
||||
expect(preview).toHaveStyle({
|
||||
bottom: '7%',
|
||||
left: '3%',
|
||||
opacity: '0.6',
|
||||
width: '11%'
|
||||
});
|
||||
expect(preview.style.right).toBe('');
|
||||
expect(preview.style.top).toBe('');
|
||||
});
|
||||
|
||||
it('does not render the preview when an external logo URL is set', async () => {
|
||||
mockApi({
|
||||
channelOverrides: {
|
||||
logo: { path: 'iptv/logos/cartoons.png', contentType: 'image/png' },
|
||||
watermarkId: 9
|
||||
},
|
||||
watermarks: [{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }]
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
// Preview renders from the uploaded logo first.
|
||||
await screen.findByAltText('On-screen bug preview');
|
||||
|
||||
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
|
||||
|
||||
// An external URL can never resolve to a real bug (see the comment on the WatermarkSelector
|
||||
// File.Exists behavior), so the preview must disappear once one is set.
|
||||
await waitFor(() => expect(screen.queryByAltText('On-screen bug preview')).toBeNull());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
Upload
|
||||
} from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ChannelLogo, Input, Select, Switch } from '../components';
|
||||
import { Badge, BugPreview, Button, Card, ChannelLogo, Input, Select, Switch, type BugPreviewGeometry } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
findLogoBugWatermark,
|
||||
getChannelById,
|
||||
getChannels,
|
||||
getChannelStreamSelectors,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
getFillerPresets,
|
||||
getLanguages,
|
||||
getMusicVideoCreditsTemplates,
|
||||
getWatermark,
|
||||
getWatermarks,
|
||||
messageFromError,
|
||||
updateChannel,
|
||||
@@ -672,6 +674,73 @@ function BrandingPane({
|
||||
const trimmedUrl = externalLogoUrl.trim();
|
||||
const previewSrc = trimmedUrl ? externalLogoUrl : (logoPreview ?? existingLogo);
|
||||
|
||||
// Reflect the referenced row — never a search — so a channel bound to a second ChannelLogo
|
||||
// preset reads correctly and is never silently repointed.
|
||||
const referenced = data.watermarks.find((watermark) => watermark.id === draft.watermarkId) ?? null;
|
||||
const logoBugEnabled = referenced?.imageSource === 'ChannelLogo';
|
||||
const logoBugTarget = findLogoBugWatermark(data.watermarks);
|
||||
// An external-URL logo is resolved by WatermarkSelector to the URL itself and then File.Exists-ed,
|
||||
// which is never true, so no bug renders (#502). Don't promise one in the preview.
|
||||
const externalUrlLogo = trimmedUrl.length > 0;
|
||||
|
||||
// Keyed by the watermark id it was fetched for, so a stale response (or a disabled toggle) is
|
||||
// filtered out by comparing against the CURRENT draft.watermarkId at render time — no reset
|
||||
// branch is needed inside the effect (avoids a synchronous setState-in-effect).
|
||||
const [fetchedGeometry, setFetchedGeometry] = useState<null | ({
|
||||
horizontalMargin: number;
|
||||
id: number;
|
||||
location: BugPreviewGeometry['location'];
|
||||
opacity: number;
|
||||
size: BugPreviewGeometry['size'];
|
||||
verticalMargin: number;
|
||||
width: number;
|
||||
})>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logoBugEnabled || draft.watermarkId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const watermarkId = draft.watermarkId;
|
||||
void getWatermark(watermarkId)
|
||||
.then((watermark) => {
|
||||
if (!cancelled) {
|
||||
setFetchedGeometry({
|
||||
horizontalMargin: watermark.horizontalMargin,
|
||||
id: watermarkId,
|
||||
location: watermark.location,
|
||||
opacity: watermark.opacity,
|
||||
size: watermark.size,
|
||||
verticalMargin: watermark.verticalMargin,
|
||||
width: watermark.width
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave any prior geometry as-is; the id-match check below keeps stale data from a
|
||||
// different watermark from being shown.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [draft.watermarkId, logoBugEnabled]);
|
||||
|
||||
const bugGeometry = fetchedGeometry && fetchedGeometry.id === draft.watermarkId ? fetchedGeometry : null;
|
||||
|
||||
// Remembers the last logo-driven watermark this channel actually referenced — which may be a
|
||||
// per-channel-geometry ChannelLogo preset other than logoBugTarget (the shared default). Without
|
||||
// this, toggling off (watermarkId -> null) then back on would always rebind to logoBugTarget,
|
||||
// silently losing a custom preset's geometry. Only updated while the toggle reads as ON, so a
|
||||
// toggle-off never clobbers the memory of what to restore.
|
||||
const lastLogoWatermarkIdRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (logoBugEnabled && draft.watermarkId != null) {
|
||||
lastLogoWatermarkIdRef.current = draft.watermarkId;
|
||||
}
|
||||
}, [logoBugEnabled, draft.watermarkId]);
|
||||
|
||||
return (
|
||||
<Pane subtitle="Channel logo and the overlays applied while streaming." title="Branding">
|
||||
<Card padded={false}>
|
||||
@@ -723,6 +792,44 @@ function BrandingPane({
|
||||
value={externalLogoUrl}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
control={340}
|
||||
help={
|
||||
logoBugTarget == null
|
||||
? 'No logo-driven watermark preset exists yet.'
|
||||
: externalUrlLogo
|
||||
? 'An external logo URL cannot be used as the on-screen bug — upload an image instead.'
|
||||
: 'Overlays this channel’s own logo on the stream, using the shared preset’s position and size.'
|
||||
}
|
||||
label="Use logo as on-screen bug"
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Switch
|
||||
checked={logoBugEnabled}
|
||||
disabled={hlsDirect || logoBugTarget == null}
|
||||
hideLabel
|
||||
label="Use logo as on-screen bug"
|
||||
onChange={(next) =>
|
||||
set({ watermarkId: next ? (lastLogoWatermarkIdRef.current ?? logoBugTarget?.id ?? null) : null })
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
{logoBugEnabled && bugGeometry && previewSrc && !externalUrlLogo && (
|
||||
// bugGeometry is cached keyed by `id` (see fetchedGeometry above); pass the
|
||||
// geometry fields explicitly rather than spreading so the cache key never leaks
|
||||
// into BugPreview's props.
|
||||
<BugPreview
|
||||
horizontalMargin={bugGeometry.horizontalMargin}
|
||||
location={bugGeometry.location}
|
||||
opacity={bugGeometry.opacity}
|
||||
size={bugGeometry.size}
|
||||
src={previewSrc}
|
||||
verticalMargin={bugGeometry.verticalMargin}
|
||||
width={bugGeometry.width}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
|
||||
<Select
|
||||
disabled={hlsDirect}
|
||||
|
||||
@@ -52,6 +52,8 @@ interface MockOptions {
|
||||
ffmpegSettings?: Record<string, unknown>;
|
||||
// path -> ProblemDetails body returned with a 422 for a bulk mutation.
|
||||
mutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
|
||||
watermarks?: unknown[] | null;
|
||||
watermarksFail?: boolean;
|
||||
}
|
||||
|
||||
interface MockHandle {
|
||||
@@ -65,6 +67,7 @@ function mockApi(options: MockOptions = {}): MockHandle {
|
||||
const channelStates = options.channelStates ?? [];
|
||||
const ffmpegSettings = options.ffmpegSettings ?? ffmpegSettingsFixture();
|
||||
const mutationFailures = options.mutationFailures ?? {};
|
||||
const watermarks = options.watermarks ?? [];
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
@@ -81,6 +84,12 @@ function mockApi(options: MockOptions = {}): MockHandle {
|
||||
if (url === '/api/v1/channels' && method === 'GET') return Promise.resolve(jsonResponse(channels));
|
||||
if (url === '/api/v1/channels/state' && method === 'GET') return Promise.resolve(jsonResponse(channelStates));
|
||||
if (url === '/api/v1/settings/ffmpeg' && method === 'GET') return Promise.resolve(jsonResponse(ffmpegSettings));
|
||||
if (url === '/api/v1/watermarks' && method === 'GET') {
|
||||
if (options.watermarksFail) {
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(watermarks));
|
||||
}
|
||||
|
||||
if (
|
||||
url === '/api/v1/channels/bulk/renumber' ||
|
||||
@@ -564,4 +573,48 @@ describe('ChannelsScreen — bare create', () => {
|
||||
expect(window.location.pathname).toBe('/app/channels');
|
||||
expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #67: one uploaded channel logo drives both the guide listing and the on-screen bug. A newly
|
||||
// created channel should default to that shared preset rather than starting with no watermark.
|
||||
it('defaults a newly created channel to the logo-bug watermark preset', async () => {
|
||||
const handle = mockApi({
|
||||
channels: [channelRow()],
|
||||
createChannelResponse: { id: 42 },
|
||||
watermarks: [{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }]
|
||||
});
|
||||
|
||||
render(<ChannelsScreen />);
|
||||
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels', expect.objectContaining({ method: 'POST' }))
|
||||
);
|
||||
|
||||
const body = requestBodyFor(handle, '/api/v1/channels');
|
||||
expect(body).toMatchObject({ watermarkId: 9 });
|
||||
});
|
||||
|
||||
// A watermark-list failure must degrade to no default, never block channel creation.
|
||||
it('still creates the channel when the watermark lookup fails', async () => {
|
||||
const handle = mockApi({
|
||||
channels: [channelRow()],
|
||||
createChannelResponse: { id: 42 },
|
||||
watermarksFail: true
|
||||
});
|
||||
|
||||
render(<ChannelsScreen />);
|
||||
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/channels', expect.objectContaining({ method: 'POST' }))
|
||||
);
|
||||
|
||||
const body = requestBodyFor(handle, '/api/v1/channels');
|
||||
expect(body).toMatchObject({ watermarkId: null });
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
bulkRenumberChannels,
|
||||
createChannel,
|
||||
deleteChannel,
|
||||
findLogoBugWatermark,
|
||||
getFfmpegSettings,
|
||||
getWatermarks,
|
||||
messageFromError,
|
||||
useChannelsScreenQuery,
|
||||
type ChannelState,
|
||||
@@ -347,6 +349,15 @@ export function ChannelsScreen() {
|
||||
return Number.isFinite(parsed) ? Math.max(max, parsed) : max;
|
||||
}, 0);
|
||||
|
||||
// New channels default to the seeded logo-driven preset so one uploaded logo drives both the
|
||||
// guide listing and the on-screen bug (#67). A lookup failure must not block channel creation.
|
||||
let watermarkId: null | number = null;
|
||||
try {
|
||||
watermarkId = findLogoBugWatermark(await getWatermarks())?.id ?? null;
|
||||
} catch {
|
||||
watermarkId = null;
|
||||
}
|
||||
|
||||
const body: CreateChannelRequest = {
|
||||
categories: null,
|
||||
fallbackFillerId: null,
|
||||
@@ -374,7 +385,7 @@ export function ChannelsScreen() {
|
||||
streamSelectorMode: 'Default',
|
||||
subtitleMode: 'None',
|
||||
transcodeMode: 'OnDemand',
|
||||
watermarkId: null
|
||||
watermarkId
|
||||
};
|
||||
|
||||
const created = await createChannel(body);
|
||||
|
||||
@@ -95,4 +95,15 @@ describe('WatermarksScreen', () => {
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Logo (copy)')).toBeInTheDocument());
|
||||
expect(screen.getByText('New watermark')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a geometry preview of the watermark image in the editor', async () => {
|
||||
window.history.pushState({}, '', '/app/watermarks/3');
|
||||
mockApi();
|
||||
render(<WatermarksScreen />);
|
||||
|
||||
const preview = await screen.findByAltText('On-screen bug preview');
|
||||
expect(preview).toHaveAttribute('src', '/artwork/watermarks/logo.png');
|
||||
// BottomRight at 5% margins, 15% width, 100% opacity (see watermarkDetail fixture)
|
||||
expect(preview).toHaveStyle({ bottom: '5%', right: '5%', width: '15%' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { ArrowLeft, Check, Copy, Image as ImageIcon, Plus, Trash2, TriangleAlert, Upload } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { usePrimaryAction } from '../primaryAction';
|
||||
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
|
||||
import { Badge, BugPreview, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
|
||||
import {
|
||||
createWatermark,
|
||||
deleteWatermark,
|
||||
@@ -532,10 +532,14 @@ function WatermarkEditor({ mode }: { mode: { id: number; kind: 'edit' } | { from
|
||||
Upload image
|
||||
</Button>
|
||||
{custom && draft.image && (
|
||||
<img
|
||||
alt="Watermark preview"
|
||||
<BugPreview
|
||||
horizontalMargin={draft.horizontalMargin}
|
||||
location={draft.location}
|
||||
opacity={draft.opacity}
|
||||
size={draft.size}
|
||||
src={watermarkImageUrl(draft.image)}
|
||||
style={{ maxHeight: 60, maxWidth: 200, objectFit: 'contain' }}
|
||||
verticalMargin={draft.verticalMargin}
|
||||
width={draft.width}
|
||||
/>
|
||||
)}
|
||||
{uploadError && (
|
||||
|
||||
Reference in New Issue
Block a user