feat(67): one logo drives both the listing and the on-screen bug #504

Merged
timothy merged 20 commits from feat/67-unified-logo-bug into main 2026-07-20 21:36:45 +02:00
Owner

Closes #67.

What this does

One uploaded channel logo now drives both the guide listing and the on-screen bug (watermark), by default, with a preview that shows where the bug will actually land.

The finding that shaped the design

Most of #67 already existed. ChannelWatermarkImageSource.ChannelLogo resolves each channel's own ArtworkKind.Logo artwork at FFmpeg render time, at every watermark precedence level — so one shared preset serves every channel. Production has been running exactly this pattern by hand since before the issue was filed: 43 channels all pointing at one hand-made Channel Bug row.

So this PR productizes a proven mechanism rather than inventing one. The real gaps were the default, the preview, and the wiring — not the capability. The issue body's premise ("Channel already has separate Logo and Watermark fields… mostly a create/edit-flow convenience") is wrong on the details and has been corrected in the issue.

No database migration. No change to how watermarks render.

Changes

Backend

  • DbInitializer.SeedChannelBugWatermark seeds one shared Channel Bug preset (ImageSource=ChannelLogo, Permanent, TopLeft, Scaled 5% width, 1%/1% margins, 80% opacity — the values production already proved).
    • An existing same-name row is adopted untouched — an operator's tuned geometry is never overwritten.
    • A ConfigElement marker (watermark.channel_bug_seeded) makes it seed once per database, not once per name-absence. ChannelWatermark has no IsSystem flag and Initialize runs at every startup, so a name-only guard would resurrect a deliberately deleted preset forever.
    • Seeded before the channel templates so its id can be stamped on templates the seed creates — giving fresh installs the default through the builder flow too. Existing installs' templates are untouched.
  • WatermarkResponseModel gains imageSource (additive under the frozen-additive /api/v1, #286) so a client identifies logo-driven presets generically instead of matching a user-editable name.

SPA

  • New shared <BugPreview> component — a 16:9 frame placing the image by location/size/margins/opacity, with the geometry math in a pure, unit-tested bugPreviewStyle.
  • Channel editor Branding tab: a "Use logo as on-screen bug" toggle plus a preview using the referenced preset's real geometry (fetched via the existing GET /api/v1/watermarks/{id}).
  • Watermarks screen: its raw <img> becomes <BugPreview>, so the screen where geometry is edited finally shows it.
  • Quick-add channel creation defaults to the preset; a failed lookup degrades to null rather than blocking creation.

Production safety

The toggle is a pure reflection of stored state: ticked iff the watermark watermarkId points at has imageSource === 'ChannelLogo', resolved by id lookup.

An earlier draft of the plan derived it by searching the list for the first ChannelLogo entry. Because getWatermarks() sorts by name, that would have shown the toggle OFF for a logo-driven channel bound to a non-first preset — and silently repointed it on tick. Caught in independent review before any code was written; pinned now by a two-preset regression test plus a "re-save untouched leaves watermarkId byte-identical" test.

Scope notes

  • The default applies to newly created channels. An existing channel doesn't gain a bug retroactively when a logo is uploaded — flip the toggle.
  • A logo set via External logo URL cannot drive the bug: WatermarkSelector resolves it to the URL then File.Exists-checks it, which is never true. Pre-existing defect, filed as #502; this PR only stops the preview from promising it.
  • MiddleCenter renders bottom-right in FFmpeg (missing switch arm) — filed as #503. The preview keeps the semantically-correct centered rendering rather than encoding the defect.
  • ChannelBuilder and POST /api/v1/channels/auto-tune are deliberately untouched on existing installs.

Verification

Gate Result
Backend tests (6 projects) 3939 passed, 0 failed (1 skipped)
web (vitest) 889 / 889 across 99 files
eslint · tsc · check:api clean · clean · clean
dotnet format --verify-no-changes exit 0 over 8 touched files
UTF-8 BOM sweep clean (both touched files' inherited BOMs stripped, #311)

Three vacuous-test blind spots were found and closed during the run, the last by the whole-branch review: the template-stamping assertion was a foreach over a possibly-empty collection. It is now proven non-vacuous by a negative control — forcing template seeding to bail makes exactly that test fail.

Reviews

Design and plan reviewed cold before implementation (found the repointing blocker, the seed-resurrection gap, and the hardcoded-preview-geometry problem — all fixed pre-implementation). Every task independently reviewed; whole-branch review returned MERGEABLE.

🤖 Generated with Claude Code

Closes #67. ## What this does One uploaded channel logo now drives **both** the guide listing and the on-screen bug (watermark), by default, with a preview that shows where the bug will actually land. ## The finding that shaped the design Most of #67 already existed. `ChannelWatermarkImageSource.ChannelLogo` resolves each channel's *own* `ArtworkKind.Logo` artwork at FFmpeg render time, at every watermark precedence level — so **one shared preset serves every channel**. Production has been running exactly this pattern by hand since before the issue was filed: 43 channels all pointing at one hand-made `Channel Bug` row. So this PR productizes a proven mechanism rather than inventing one. The real gaps were the *default*, the *preview*, and the *wiring* — not the capability. The issue body's premise ("Channel already has separate `Logo` and `Watermark` fields… mostly a create/edit-flow convenience") is wrong on the details and has been corrected in the issue. **No database migration.** No change to how watermarks render. ## Changes **Backend** - `DbInitializer.SeedChannelBugWatermark` seeds one shared `Channel Bug` preset (`ImageSource=ChannelLogo`, Permanent, TopLeft, Scaled 5% width, 1%/1% margins, 80% opacity — the values production already proved). - An existing same-name row is **adopted untouched** — an operator's tuned geometry is never overwritten. - A `ConfigElement` marker (`watermark.channel_bug_seeded`) makes it seed **once per database**, not once per name-absence. `ChannelWatermark` has no `IsSystem` flag and `Initialize` runs at every startup, so a name-only guard would resurrect a deliberately deleted preset forever. - Seeded *before* the channel templates so its id can be stamped on templates the seed creates — giving fresh installs the default through the builder flow too. Existing installs' templates are untouched. - `WatermarkResponseModel` gains `imageSource` (additive under the frozen-additive `/api/v1`, #286) so a client identifies logo-driven presets generically instead of matching a user-editable name. **SPA** - New shared `<BugPreview>` component — a 16:9 frame placing the image by location/size/margins/opacity, with the geometry math in a pure, unit-tested `bugPreviewStyle`. - Channel editor Branding tab: a **"Use logo as on-screen bug"** toggle plus a preview using the referenced preset's **real** geometry (fetched via the existing `GET /api/v1/watermarks/{id}`). - Watermarks screen: its raw `<img>` becomes `<BugPreview>`, so the screen where geometry is *edited* finally shows it. - Quick-add channel creation defaults to the preset; a failed lookup degrades to `null` rather than blocking creation. ## Production safety The toggle is a **pure reflection of stored state**: ticked iff the watermark `watermarkId` *points at* has `imageSource === 'ChannelLogo'`, resolved by **id lookup**. An earlier draft of the plan derived it by searching the list for the first `ChannelLogo` entry. Because `getWatermarks()` sorts by name, that would have shown the toggle OFF for a logo-driven channel bound to a non-first preset — and **silently repointed it** on tick. Caught in independent review before any code was written; pinned now by a two-preset regression test plus a "re-save untouched leaves `watermarkId` byte-identical" test. ## Scope notes - The default applies to **newly created** channels. An existing channel doesn't gain a bug retroactively when a logo is uploaded — flip the toggle. - A logo set via **External logo URL** cannot drive the bug: `WatermarkSelector` resolves it to the URL then `File.Exists`-checks it, which is never true. Pre-existing defect, filed as **#502**; this PR only stops the preview from promising it. - `MiddleCenter` renders bottom-right in FFmpeg (missing switch arm) — filed as **#503**. The preview keeps the semantically-correct centered rendering rather than encoding the defect. - `ChannelBuilder` and `POST /api/v1/channels/auto-tune` are deliberately untouched on existing installs. ## Verification | Gate | Result | |---|---| | Backend tests (6 projects) | **3939 passed, 0 failed** (1 skipped) | | web (vitest) | **889 / 889** across 99 files | | eslint · tsc · `check:api` | clean · clean · clean | | `dotnet format --verify-no-changes` | exit 0 over 8 touched files | | UTF-8 BOM sweep | clean (both touched files' inherited BOMs stripped, #311) | Three vacuous-test blind spots were found and closed during the run, the last by the whole-branch review: the template-stamping assertion was a `foreach` over a possibly-empty collection. It is now **proven non-vacuous by a negative control** — forcing template seeding to bail makes exactly that test fail. ## Reviews Design and plan reviewed cold before implementation (found the repointing blocker, the seed-resurrection gap, and the hardcoded-preview-geometry problem — all fixed pre-implementation). Every task independently reviewed; whole-branch review returned **MERGEABLE**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 20 commits 2026-07-20 21:14:37 +02:00
Recon showed ChannelWatermarkImageSource.ChannelLogo already resolves the
channel's logo artwork at render time, and prod already runs that pattern
by hand (43 channels -> one 'Channel Bug' preset). So #67 is productizing
a proven mechanism, not building one: seed the preset idempotently in
DbInitializer (adopting any existing same-name row untouched), default the
SPA creation paths to it, and add a shared <BugPreview> that renders the
resolved geometry in both the channel editor and WatermarksScreen.

No schema change, no WatermarkSelector change. One additive DTO field
(WatermarkResponseModel.imageSource) under the frozen-additive /api/v1.

Refs #67
Self-review against the spec caught that the spec named both SPA creation
paths, but ChannelBuilder does not hardcode a watermark -- it inherits
watermarkId from the selected ChannelTemplate. Defaulting it would mean
mutating the seeded Standard/Music videos template rows, which the seed's
name-guard deliberately skips on existing installs, so it would no-op on
prod while changing fresh installs. Excluded in both documents with the
reason recorded.

Refs #67
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
Extracts on-screen bug (watermark) placement math into a pure,
DOM-free bugPreviewStyle(geometry) function plus a BugPreview
component that renders it inside a 16:9 frame. Consumed by the
channel and playout watermark editor screens (tasks 4/5) so users
can preview bug placement before saving.
WatermarkResponseModel gains ImageSource so a client can identify
logo-driven presets generically instead of matching a user-editable name.
Additive under the frozen-additive /api/v1 contract (#286).

Adding a positional record parameter is source-breaking for existing
constructor call sites, so the two test files that built the DTO
positionally are updated. WatermarkHandlerTests now seeds its two rows with
DIFFERENT image sources so the round-trip assertion proves the field is
actually carried through the mapper rather than matching a constant on both.

Regenerated v1.json, endpoint-index.md and v1.d.ts; check:api clean.
Stripped the inherited UTF-8 BOM from Mapper.cs (#311 fix-as-you-touch).

Refs #67
Replace the raw <img> preview in the Watermarks editor's Image row with
the shared BugPreview component, so the on-screen bug's location/size/
margins/opacity render the same way as the other three preview spots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add ChannelEditScreen tests exercising the geometry-fetch/preview path (previously
  untested because the fixture's blank logo path kept BugPreview from ever rendering):
  asserts the fetched watermark geometry lands on the preview image with asymmetric
  values, and that an external logo URL suppresses the preview.
- Fix a toggle-off/toggle-on data loss: re-enabling "use logo as on-screen bug" used to
  always rebind to the default 'Channel Bug' preset, discarding a channel's own
  per-geometry ChannelLogo preset. Remember the last referenced logo-driven watermark id
  in a ref and prefer it, falling back to the shared default.
- Strip the geometry cache's `id` key before it reaches BugPreview's props (was leaking
  via a spread).
- Drop the "(see #502)" issue reference from the visible help text; keep it in the code
  comment.
- Constrain findLogoBugWatermark's imageSource param to the generated
  ChannelWatermarkImageSource union instead of `string`, and add unit tests for its
  preference/fallback/none-found behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(67): de-vacuify the template-stamping assertion; cover adopt-then-delete
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 1m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m25s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m1s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
893d4a6398
Whole-branch review found the stamping test was structurally vacuous: a bare
foreach over ChannelTemplates.Where(IsSystem) passes with zero iterations, so
the test would have stayed green if template seeding silently bailed out.
Assert the collection is non-empty first. Proven non-vacuous by a negative
control (forcing SeedChannelTemplates to bail makes exactly this test fail).

Also cover the ACTUAL production sequence -- adopt an existing hand-made row,
then delete it -- which the previous no-resurrect test did not exercise (it
covered seed-then-delete). The marker is written on the adopt path too, so
the deleted row must stay deleted.

docs: note that a deleted preset degrades to no default rather than failing,
and that the default applies to newly created channels, not retroactively.

Refs #67
Author
Owner

Live-E2E: PASSED (real running instance, fresh database)

Ran per docs/e2e-local.md against a fresh config dir on an isolated port — not prod, not a shared instance.

1. Seed produces the preset, with the exact specified geometry

GET /api/v1/watermarks       → [{"id":1,"name":"Channel Bug","imageSource":"ChannelLogo"}]
GET /api/v1/watermarks/1     → {"mode":"Permanent","location":"TopLeft","size":"Scaled",
                                "width":5.0,"horizontalMargin":1.0,"verticalMargin":1.0,
                                "opacity":80,"image":null}

This also confirms the additive imageSource field is live on the picker DTO, and that the by-id call the editor's preview depends on returns the real geometry.

2. Fresh-install template stamping works

ChannelTemplate: Standard|IsSystem=1|WatermarkId=1
                 Music videos|IsSystem=1|WatermarkId=1
ConfigElement:   watermark.channel_bug_seeded|true

3. Write path round-trips through a subsequent read (the #229 lazy-materialisation net)

POST /api/v1/channels  {... "watermarkId":1}  → {"id":2,"name":"E2E Logo Bug","watermarkId":1}
GET  /api/v1/channels/2                       → {"id":2,"watermarkId":1}

4. The marker genuinely prevents resurrection — the behaviour the unit test asserts, now proven against a real app: deleted the seeded preset, restarted the process against the same database, and it stayed deleted (COUNT(*) = 0, marker still present). A name-only guard would have recreated it here.

Full verification

Gate Result
Backend tests (6 projects) 3939 passed, 0 failed (1 skipped)
web (vitest) 889 / 889 across 99 files
eslint · tsc · check:api clean · clean · clean
dotnet format --verify-no-changes exit 0 over 8 touched files
UTF-8 BOM sweep clean
Live-E2E passed (above)

Reviews

  • Pre-implementation (cold, review-only): caught a data-integrity blocker in the plan's own code — deriving the toggle by searching for the first ChannelLogo preset would have silently repointed channels bound to a non-first one, because getWatermarks() sorts by name. Fixed before any code was written; now pinned by a two-preset regression test.
  • Per-task: every task independently reviewed. Findings fixed, including an untested 50-line geometry path and toggle-off/on losing a custom preset.
  • Whole-branch: found the template-stamping assertion was structurally vacuous (a foreach over a possibly-empty collection). Fixed in 893d4a63 and proven non-vacuous by a negative control — forcing template seeding to bail makes exactly that test fail.

Review-verdict: MERGEABLE @ 893d4a6398

## Live-E2E: PASSED (real running instance, fresh database) Ran per `docs/e2e-local.md` against a fresh config dir on an isolated port — not prod, not a shared instance. **1. Seed produces the preset, with the exact specified geometry** ``` GET /api/v1/watermarks → [{"id":1,"name":"Channel Bug","imageSource":"ChannelLogo"}] GET /api/v1/watermarks/1 → {"mode":"Permanent","location":"TopLeft","size":"Scaled", "width":5.0,"horizontalMargin":1.0,"verticalMargin":1.0, "opacity":80,"image":null} ``` This also confirms the additive `imageSource` field is live on the picker DTO, and that the by-id call the editor's preview depends on returns the real geometry. **2. Fresh-install template stamping works** ``` ChannelTemplate: Standard|IsSystem=1|WatermarkId=1 Music videos|IsSystem=1|WatermarkId=1 ConfigElement: watermark.channel_bug_seeded|true ``` **3. Write path round-trips through a subsequent read** (the #229 lazy-materialisation net) ``` POST /api/v1/channels {... "watermarkId":1} → {"id":2,"name":"E2E Logo Bug","watermarkId":1} GET /api/v1/channels/2 → {"id":2,"watermarkId":1} ``` **4. The marker genuinely prevents resurrection** — the behaviour the unit test asserts, now proven against a real app: deleted the seeded preset, restarted the process against the **same** database, and it stayed deleted (`COUNT(*) = 0`, marker still present). A name-only guard would have recreated it here. ## Full verification | Gate | Result | |---|---| | Backend tests (6 projects) | 3939 passed, 0 failed (1 skipped) | | web (vitest) | 889 / 889 across 99 files | | eslint · tsc · `check:api` | clean · clean · clean | | `dotnet format --verify-no-changes` | exit 0 over 8 touched files | | UTF-8 BOM sweep | clean | | Live-E2E | passed (above) | ## Reviews - **Pre-implementation** (cold, review-only): caught a data-integrity blocker in the plan's own code — deriving the toggle by searching for the first `ChannelLogo` preset would have silently repointed channels bound to a non-first one, because `getWatermarks()` sorts by name. Fixed before any code was written; now pinned by a two-preset regression test. - **Per-task**: every task independently reviewed. Findings fixed, including an untested 50-line geometry path and toggle-off/on losing a custom preset. - **Whole-branch**: found the template-stamping assertion was structurally vacuous (a `foreach` over a possibly-empty collection). Fixed in `893d4a63` and **proven non-vacuous by a negative control** — forcing template seeding to bail makes exactly that test fail. Review-verdict: MERGEABLE @ 893d4a63989b2a0cc2d09d67bf11d3c07a88bf76
timothy merged commit 72dc1c9fba into main 2026-07-20 21:36:45 +02:00
timothy deleted branch feat/67-unified-logo-bug 2026-07-20 21:36:48 +02:00
Sign in to join this conversation.