---
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.
