Files
ersatztv/docs/decisions/records/ffmpeg/watermark-resolution-unified.md
T
timothy bc1a37ff01
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 16s
Review verdict / Set review-verdict status (pull_request) Successful in 4s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
PR Gates / Script tests (pytest) (pull_request) Successful in 35s
review-verdict/h10 Review-verdict: MERGEABLE @ bc1a37f
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m32s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m17s
fix(510): pin the blank-guard's is-Custom discriminator, verified by mutation
Round-4 review found the fall-through tests, while now falsifiable, still did
not pin the whole guard. Two gaps, both closed and both verified by running the
mutation rather than by asserting the test would catch it:

1. All three fall-through tests used only "   ", so narrowing
   IsNullOrWhiteSpace to `image == "   "` would have passed while breaking
   fall-through for null and "" -- and null is the form the API actually
   persists. Parameterized over null, "" and "   ".

2. Nothing pinned the guard's `ImageSource is Custom` clause. This is the
   sharper of the two: a ChannelLogo watermark's Image is NORMALLY blank
   (the API writes Image = null for every non-Custom source), so dropping the
   discriminator would send every playout-item ChannelLogo watermark down the
   fall-through path instead of resolving the channel's own logo -- with a
   fully green suite. Added
   Blank_Image_ChannelLogo_Playout_Item_Watermark_Should_Win_And_Not_Fall_Through,
   which distinguishes the two levels by watermark Id so a fall-through is
   observable even though both resolve to the same cached path.

Mutation results (each mutation applied on its own, then reverted):

  drop `is Custom` from the guard  -> 1 failure, and the new test is the ONLY
                                      test that catches it
  IsNullOrWhiteSpace -> == "   "   -> the null and "" parameterized cases fail

Negative control re-measured on the final 32-case fixture: 19 fail against the
origin/main resolver. The 13 that pass both ways pin deliberately preserved
behavior plus the positive control, which the record now states explicitly
along with the mutation table.

This round's lesson, recorded in the record: a test's NAME is not evidence it
pins what it claims, and a whole-file revert cannot show that a test aimed at a
specific clause actually reaches that clause -- only mutating the clause can.

Gates: 2661 tests green across 4 projects, 225/225 script tests (the gate I
skipped before the last push), decisions-validate OK, format exit 0, no BOMs.

refs #510
2026-07-26 21:53:01 +02:00

9.7 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ffmpeg.watermark-resolution-unified 2026-07-26 — One watermark resolver for all four attachment points; no usable logo means no bug (#510) active 2026-07-26 none none Every watermark `WatermarkSelector` resolves goes through one shared `ResolveWatermark` — the playout-item, channel and global precedence levels AND the deco path, for all three `ChannelWatermarkImageSource` values. An unresolvable watermark (missing file, un-migrated external URL, or no logo artwork) resolves to no on-screen bug plus a warning, never a dead path or a URL handed downstream; the one deliberate exception is a playout-item `Custom` with a blank image, which still falls THROUGH to channel/global. The generated-initials fallback is therefore off everywhere, including the deco path where it demonstrably rendered. Watermarks built OUTSIDE the selector (the song-progress overlay, #653) are not covered and remain unchecked. deco watermark, missing logo policy, generated initials, nameplate bug, watermark resolution divergence, song-progress overlay bypass · paths: `ErsatzTV.Core/FFmpeg/WatermarkSelector.cs` (`ResolveWatermark`, `ChannelLogoWatermarkOptions`, `OptionsForWatermarks`), `ChannelLogoGenerator.GenerateChannelLogoUrl` · issues: #510, #502, #525, #1, #67, #652, #653 `WatermarkSelectorDecoResolutionTests` (incl. the `Deco_And_Channel_Level_Should_Resolve_Identically` parity cases); `docs/channels.md` → Watermarks

WatermarkSelector resolved a watermark in two places with two policies. The deco path had its own copy of the image-source switch that returned whatever path it computed, unchecked. The precedence levels checked — though not uniformly: the playout-item level checked all three sources, while the channel and global levels checked Custom/ChannelLogo and threw for Resource (they had no arm for it, so it fell to default:). So one channel could disagree with itself about whether a bug rendered, based only on how the watermark was attached.

#502 had noted the split and deferred it here. What #502's note got wrong is scope: the divergence was never ChannelLogo-only. The deco path skipped the existence check for Custom and Resource too — three arms, not one. Fixing the named arm and leaving two is how this defect reached triplicate in the first place, so the selector now has exactly one resolver. One piece of per-caller policy survives by design, and it lives in the caller rather than the resolver: the playout-item blank-Custom fall-through described below.

Scope boundary, found by review of this very change: "one resolver" is true of the selector, not of the application. GetPlayoutItemProcessByChannelNumberHandler and PrepareTroubleshootingPlaybackHandler build the song-progress overlay's WatermarkOptions by hand with an unchecked Path.Combine(ResourcesCacheFolder, …) and then watermarks.Clear(), so they bypass ResolveWatermark and can still hand ffmpeg a nonexistent -i (the exact hazard described below). Pre-existing and left alone here; tracked as #653. An earlier draft of this record claimed the hazard was closed outright — it is closed only for watermarks the selector resolves.

Severity is not merely cosmetic, which is what settled the direction. A dead local path is not harmlessly skipped: CanUseFFmpegNativeWatermark routes a single permanent watermark to ffmpeg as a bare -i argument, and only URLs are excluded from that shortcut. So the deco path could hand ffmpeg a nonexistent input file. A URL, by contrast, reaches the graphics engine, whose fetch failures are caught into "overlay disabled". Unchecked resolution was the more dangerous of the two policies.

The generated-initials fallback was real, and is deliberately switched off. With no logo artwork the deco path returned ChannelLogoGenerator.GenerateChannelLogoUrl — a localhost URL. A live-E2E on a real transcoded frame (deco in Override mode, ChannelLogo watermark, channel with no logo artwork) confirmed the nameplate did composite: /iptv/logos/gen sits on ArtworkController, which carries no auth filter (unlike IptvController), so the container-internal self-fetch succeeded. This is recorded because the #502-era comment asserted the opposite ("it has never rendered here") and a future reader would otherwise re-inherit that error.

It is still removed, because keeping it means an HTTP fetch inside stream startup per element init — precisely what graphics.channel-logo-caching (#525) eliminated for logos — and it depends on #1's hardcoded localhost, which #1 closed as a topology problem without removing. Keeping it only on the deco path would preserve the exact incoherence this record exists to remove.

Prod blast radius was measured, not assumed: 0 Deco rows, 0 DecoWatermark rows, and all 43 channels have logo artwork — so no rendered output changes. That measurement is what made "no bug" affordable over the two alternatives (enable initials everywhere; or cache the initials into the image cache and then enable). The second alternative is the right end-state and is rejected only on scope, not on merit: generating the image into the image cache would make it a local path, honouring #525 and sidestepping #1 entirely. That work is #652 — anyone reviving the nameplate should do it that way rather than by reinstating a render-time URL.

Preserved deliberately: a playout-item Custom watermark with a blank image still falls through to the channel/global watermark instead of resolving to "no watermark". Unifying resolution must not change which watermark wins.

Routing: the predicate is unchanged, its input is not. CanUseFFmpegNativeWatermark still keys off the resolved path alone, so any URL still goes to the graphics engine regardless of provenance. But it also tests watermarks.Count == 1, and dropping an unresolvable watermark shortens that list — so a deco carrying one valid and one missing permanent watermark now routes ffmpeg-native (count 1) where it previously routed to the graphics engine (count 2). Intended, since what survives is a single valid permanent local image, but it is an observable routing change and an earlier draft of this record wrongly called routing untouched. Pinned by Deco_With_One_Valid_And_One_Missing_Watermark_Should_Return_Only_The_Valid_One.

Three strict improvements, all previously crash-shaped. The channel and global arms threw NotSupportedException on a Resource watermark; they now resolve it like the playout-item arm — and the Resource arm gained a blank/null guard it never had, because CreateWatermarkHandler and UpdateWatermarkHandler both write Image = null for every non-Custom watermark, so an API-created Resource watermark reached Path.Combine(folder, null) and threw ArgumentNullException at any level, including the playout-item one. Less obviously, those two arms also had no blank-image guard on Custom — only the playout-item arm did — so they called ImageCache.GetPathForImage, whose fileName[..2] throws ArgumentOutOfRangeException on "" and NullReferenceException on null. That state is reachable through the current API, not just legacy rows: CreateWatermarkHandler/UpdateWatermarkHandler assign Image = update.Image?.Path with nothing requiring an image. So a channel-level Custom watermark saved with its image cleared threw out of the selector during stream startup — killing playback, not just the overlay — and now degrades to a warning plus no watermark. No existing test could see this, because every fixture stubs IImageCache and never executes fileName[..2]. The default: arm still throws, so a newly added image source fails loudly rather than inheriting a neighbour's behavior.

The parity test cases are the structural guard: they assert deco and channel-level resolution return identical paths case for case, so re-introducing a per-caller policy fails in CI rather than in prod.

Measured mutation sensitivity: 19 of the 32 cases fail when the pre-fix resolver is restored under the final fixture. The other 13 pass both ways by design — they pin behavior this change deliberately preserves (the blank-Custom fall-through, file-present resolution, the is Custom discriminator) plus the positive control, so passing before and after is the correct outcome for them, not a gap.

Two guard mutations were run individually, because a whole-file revert cannot show that a test aimed at a specific clause actually hits it:

Mutation Caught by
drop ImageSource is Custom from the blank guard Blank_Image_ChannelLogo_Playout_Item_Watermark_Should_Win_And_Not_Fall_Through (1 failure, and the only test that catches it)
narrow IsNullOrWhiteSpace to == " " the null and "" cases of Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_A_Resolvable_Channel_Logo

That first mutation matters more than it looks: a ChannelLogo watermark's Image is normally blank, so dropping the discriminator would send every playout-item ChannelLogo watermark down the fall-through path instead of resolving the channel's own logo.

Re-measure rather than trust these figures if the fixture changes. Review of this very change found two tests that had been added to close earlier review findings yet could not fail — one whose fall-through fallback was itself unresolvable (so a wrongly-widened guard still yielded None), and one that asserted a filtered list length while the routing claim it was cited for lived in a function it never called. The lesson is that a test added under review pressure needs the same negative control as the original, and that a test's name is not evidence it pins what it claims.