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 ownArtworkKind.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 watermarkIdpoints 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.
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.
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)
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>
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
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.
## 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 main2026-07-20 21:36:45 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.ChannelLogoresolves each channel's ownArtworkKind.Logoartwork 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-madeChannel Bugrow.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
LogoandWatermarkfields… 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.SeedChannelBugWatermarkseeds one sharedChannel Bugpreset (ImageSource=ChannelLogo, Permanent, TopLeft, Scaled 5% width, 1%/1% margins, 80% opacity — the values production already proved).ConfigElementmarker (watermark.channel_bug_seeded) makes it seed once per database, not once per name-absence.ChannelWatermarkhas noIsSystemflag andInitializeruns at every startup, so a name-only guard would resurrect a deliberately deleted preset forever.WatermarkResponseModelgainsimageSource(additive under the frozen-additive/api/v1, #286) so a client identifies logo-driven presets generically instead of matching a user-editable name.SPA
<BugPreview>component — a 16:9 frame placing the image by location/size/margins/opacity, with the geometry math in a pure, unit-testedbugPreviewStyle.GET /api/v1/watermarks/{id}).<img>becomes<BugPreview>, so the screen where geometry is edited finally shows it.nullrather than blocking creation.Production safety
The toggle is a pure reflection of stored state: ticked iff the watermark
watermarkIdpoints at hasimageSource === 'ChannelLogo', resolved by id lookup.An earlier draft of the plan derived it by searching the list for the first
ChannelLogoentry. BecausegetWatermarks()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 leaveswatermarkIdbyte-identical" test.Scope notes
WatermarkSelectorresolves it to the URL thenFile.Exists-checks it, which is never true. Pre-existing defect, filed as #502; this PR only stops the preview from promising it.MiddleCenterrenders bottom-right in FFmpeg (missing switch arm) — filed as #503. The preview keeps the semantically-correct centered rendering rather than encoding the defect.ChannelBuilderandPOST /api/v1/channels/auto-tuneare deliberately untouched on existing installs.Verification
check:apidotnet format --verify-no-changesThree vacuous-test blind spots were found and closed during the run, the last by the whole-branch review: the template-stamping assertion was a
foreachover 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
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 #502Live-E2E: PASSED (real running instance, fresh database)
Ran per
docs/e2e-local.mdagainst a fresh config dir on an isolated port — not prod, not a shared instance.1. Seed produces the preset, with the exact specified geometry
This also confirms the additive
imageSourcefield 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
3. Write path round-trips through a subsequent read (the #229 lazy-materialisation net)
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
check:apidotnet format --verify-no-changesReviews
ChannelLogopreset would have silently repointed channels bound to a non-first one, becausegetWatermarks()sorts by name. Fixed before any code was written; now pinned by a two-preset regression test.foreachover a possibly-empty collection). Fixed in893d4a63and proven non-vacuous by a negative control — forcing template seeding to bail makes exactly that test fail.Review-verdict: MERGEABLE @
893d4a6398