# 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.
