Fable review (cold, review-only) found a blocker in the plan's own code:
Task 5/6 selected the logo-bug preset by searching for the first
imageSource==='ChannelLogo' entry, but getWatermarks() sorts by name
(pickers.ts:14), so with a second logo-driven preset -- which this design
explicitly invites users to create -- the toggle would read OFF for a
logo-driven channel and REPOINT it on tick, from a control documented as a
pure reflection of stored state. Now resolved by id lookup, with the
tick-on target chosen by a shared findLogoBugWatermark() helper and pinned
by a two-preset regression test.
Also adopted:
- ConfigElement seed marker (watermark.channel_bug_seeded): ChannelWatermark
has no IsSystem flag and Initialize runs every startup, so a name-only
guard resurrected a deliberately deleted preset forever.
- Channel-editor preview now fetches the referenced preset's REAL geometry
via the existing GET /api/v1/watermarks/{id} instead of hardcoding the
seeded defaults -- which would have been wrong for exactly the users who
tuned theirs.
- The 're-save untouched leaves watermarkId unchanged' test the spec
promised and the plan had omitted, plus a create-path degrade test.
- Reversed the ChannelBuilder exclusion (operator decision): fresh installs
stamp the preset onto the templates the seed creates; existing installs
are untouched.
- External-URL logos never render a bug (File.Exists against a URL,
WatermarkSelector.cs:269-286) -- verified, filed as #502, preview no
longer promises it.
- Dropped Task 2's InternalsVisibleTo branch: already present
(ErsatzTV.Application.csproj:30-32).
Refs #67 #502
12 KiB
Unified logo / on-screen bug — design (ersatztv#67)
Date: 2026-07-20 Issue: #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
- 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), withChannel.WatermarkIdpointing at one (Channel.cs:21-22). Per-channel bug settings have nowhere to live without either newChannelcolumns or one watermark row per channel. ImageSource = ChannelLogoresolves 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.- Logo is not a
Channelfield — it is anArtworkrow (ArtworkKind.Logo = 2) whosePathholds an image-cache hash or an absolute external URL, served at/iptv/logos/{hash}(ErsatzTV/Controllers/IptvController.cs:282-293). /api/v1is 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
ChannelWatermarknamedChannel Bugalready 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.
ChannelWatermarkhas noIsSystemflag (unlikeChannelTemplate), so a pure name-guard would resurrect the row on every restart after a deliberate delete or rename. AConfigElementmarker (ConfigElementKey.WatermarkChannelBugSeeded, keywatermark.channel_bug_seeded) is written on first seed or adopt; once present, the seed is a no-op. This follows the file's ownEnsureDefaultChannelTemplateConfigprecedent (: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 fromlocation(9 values,ErsatzTV.FFmpeg/State/WatermarkState.cs),size,widthPercent, horizontal/vertical margin percents andopacity. 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 hardcodeswatermarkId: null— by settingwatermarkIdto the seeded preset at create time. - The
ChannelBuilderlineup flow is covered via template stamping (revised after independent review). It does not hardcode a watermark: it inheritswatermarkIdfrom the selectedChannelTemplate(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-tuneis 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.watermarkIdpoints at hasimageSource === 'ChannelLogo'. An earlier draft searched the list for the firstChannelLogopreset; becausegetWatermarks()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 firstChannelLogopreset. With multiple logo-driven presets,imageSourcealone 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.
WatermarkSelectorresolves an external-URL logo to the URL itself and then callsFile.Existson 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 anddocs/channels.mdsay so. The underlying defect is pre-existing and tracked in its own issue, not fixed here.
- Reflect from the referenced row, not from a search. Ticked iff the watermark
- 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-539is 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 repeatedInitializecalls; does not resurrect a deleted preset once the seed marker exists; stampsWatermarkIdon freshly seeded templates only. - Vitest:
BugPreviewgeometry mapping across all 9 locations, plus opacity/size/margins. For the toggle:- loading and re-saving an existing channel without touching the toggle leaves
watermarkIdbyte-identical — the guarantee protecting the 43 production channels, and the test the first draft of this plan promised but omitted; - with two
ChannelLogopresets, 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
nullrather than aborting when the watermark lookup fails.
- loading and re-saving an existing channel without touching the toggle leaves
6. Docs to update in the same PR
Per CLAUDE.md's docs-update rule:
docs/api-conventions.mdchecklist + regeneratev1.jsonandendpoint-index.mdvia./scripts/update-openapi.sh, thennpm run generate:api(theimageSourceaddition).docs/channels.md— watermark section: the seededChannel Bugpreset 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-286resolves an external-URL logo to the URL and thenFile.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-236runsFile.Existsagainst a URL produced byChannelLogoGenerator.GenerateChannelLogoUrl, which hardcodes localhost (ChannelLogoGenerator.cs:93-94, issue #1). Deliberately deferred indocs/decisions.md:1617-1623. - The initials shown at
/app/channelson production: not a defect. That is fixed onmainby1aa1f0cf("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. tagv26.10.0). It resolves on the pending v26.11.0 deploy — the same deploy #350 and #474 are waiting on.