Files
ersatztv/docs/superpowers/specs/2026-07-20-unified-logo-bug-design.md
T
timothy 71bb2c23f3 docs(67): revise plan+spec per independent review — fix toggle blocker, seed marker, real preview geometry
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
2026-07-20 20:04:56 +02:00

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

  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.