--- key: ffmpeg.external-logo-graphics-engine title: 2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502) status: active since: '2026-07-20' supersedes: none superseded-by: none rule: External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. signals: 'external-URL logo, WatermarkSelector, graphics engine · paths: `WatermarkSelector`, `FFmpegLibraryProcessService`, `ImageElementBase.LoadImage` · issues: #502, #67, #1, #510, #511' mechanics: '`WatermarkSelectorChannelLogoTests`; `ChannelLogoWatermarkOptions` helper' --- A channel whose logo is an **external URL** never rendered an on-screen bug, even with a `ImageSource = ChannelLogo` watermark attached. `WatermarkSelector` resolved the URL correctly and then existence-checked it on the filesystem — `File.Exists("https://…")` is always false — so all three precedence levels (playout item, channel, global) logged *"Channel logo no longer exists"* and returned `None`. The channel editor advertises the URL as winning over an uploaded logo, which is true for the guide listing and was silently false for the bug. **External artwork passes through; it is not downloaded into the image cache.** That is already the codebase-wide convention — `ChannelPlaylist` (M3U), the XMLTV template data and `Channels/Mapper` (SPA JSON) all emit the raw URL and let the client fetch it. No fetch→`SaveArtworkToCache` glue exists anywhere, and adding it here would have invented a second convention for one consumer. The render path needs no such glue: `ImageElementBase.LoadImage` already detects an `http(s)` path, fetches it with `HttpClient`, and decodes it with ImageSharp for real pixel dimensions. **A remote-URL watermark is therefore forced onto the graphics engine, not the ffmpeg-native path.** `FFmpegLibraryProcessService` normally shortcuts a single permanent watermark into a `WatermarkInputFile` handed to ffmpeg as a bare `-i` argument (and to `ffprobe` for animation detection). ffmpeg would likely open an http URL itself, but that puts an unbounded network fetch *inside stream startup*, with no timeout, redirect or auth handling under our control, and with dimensions left as a `FrameSize(1, 1)` placeholder. The graphics engine is the path actually built for remote images, so the shortcut now additionally requires a non-URL path. **Scope held deliberately narrow — two adjacent defects were left alone:** - The **generated-initials fallback** (no logo artwork ⇒ `ChannelLogoGenerator.GenerateChannelLogoUrl`, which hardcodes `localhost`, issue #1) is *also* killed by the same `File.Exists`. Reviving it is deferred by an earlier entry in this file, so it stays ignored — now behind an explicit comment and a scope-guard test rather than as an accident of the existence check. - The **deco path** (`OptionsForWatermarks` → the private `GetWatermarkOptions`) has always returned its resolved path unchecked, so #502's `File.Exists` defect never reached it and its *resolution* is unchanged. Aligning its missing-file and no-artwork behavior with the three precedence levels is a behavior change in its own right, tracked as **#510**. **The routing change is NOT scoped that way, deliberately.** `CanUseFFmpegNativeWatermark` keys off the resolved `WatermarkOptions.ImagePath` alone, and `SelectWatermarks` puts deco-derived options into the same list — so a *deco* watermark resolving to a URL is rerouted to the graphics engine too, including the generated-initials `http://localhost:…/iptv/logos/gen` URL that the deco path does still pass through. Routing by provenance instead of by what the path actually is would mean deciding the same thing twice and letting the two drift; the URL-aware path is the right one for any URL. Worth knowing when picking up #510: that fallback plausibly rendered through ffmpeg before and now composites through ImageSharp, which this change's live-E2E did not cover. **Accepted cost asymmetry.** Graphics-engine compositing is per-frame ImageSharp work rather than ffmpeg's `overlay` filter, so two channels with visually identical bugs now transcode at different cost based only on whether the logo is a URL. The rejected fetch-once-into-the-image-cache alternative would have avoided that; if **#511** (remote-fetch hardening: timeout, size cap, pooling, caching) adds such a cache, this goes with it. The three gated levels now share one `ChannelLogoWatermarkOptions` helper so they cannot drift apart again — the duplication is what let the defect exist in triplicate. Covered by `WatermarkSelectorChannelLogoTests`, which pins the external-URL fix, both preserved regressions (cached local path, missing local file ignored) and the generated-fallback scope guard.