# Design — Per-channel "On Now / Next" transient overlay (#74)

- **Issue:** ersatztv#74 ("[Blue sky] On-screen 'On Now / On Next' overlay (channel-surf bug)")
- **Date:** 2026-07-22
- **Status:** approved design, pre-implementation
- **Scope chosen:** full feature — seeded preset element + per-channel SPA toggle + docs + live E2E

## 1. Summary

Burn a transient "On Now / Next" text bug onto a channel's transcoded stream, shown for
~8 seconds at each program transition, for authentic channel-surf feel. The overlay shows the
current program (title + episode/subtitle) and the next program (title + start time).

**Key finding that shaped the design:** the core capability already exists. ErsatzTV has a
SkiaSharp graphics engine (`ErsatzTV.Infrastructure/Streaming/Graphics/`) that renders dynamic
text per playout item, with EPG "current + next N programs" data already wired in as template
variables at transcode time (`TemplateDataRepository.GetEpgTemplateData`). The graphics context
is rebuilt per playout item, so overlay text changes per program automatically. **No new
rendering or EPG-data infrastructure is required.** The remaining work is (a) a **channel-level
attachment** for graphics elements (which does not exist today), (b) a **seeded preset element**,
(c) a **per-channel SPA toggle**, and (d) verification.

The issue's original worry ("requires stream-overlay work in the FFmpeg pipeline beyond the static
watermark — non-trivial") is obsolete: that infrastructure landed (upstream graphics engine +
our #502/#511 remote-image/graphics-engine work). This places #74 alongside #73/#77 — mostly
already-implemented — but the user chose to also ship the reusable channel-attach + toggle rather
than a docs-only close.

## 2. Existing systems (grounding)

- **Graphics elements** (`ErsatzTV.Core/Domain/GraphicsElement.cs`): YAML-authored
  (`Text`/`Image`/`Subtitle`/`Motion`/`Script`), rendered by the graphics engine. A DB row has
  `Path` (points to a YAML file on disk), `Name`, `Kind`. Attaches only via **PlayoutItem /
  ProgramScheduleItem / BlockItem / Deco** join tables — **no channel-level attachment.**
- **`TextGraphicsElement`** (`ErsatzTV.Core/Graphics/TextGraphicsElement.cs`): fields include
  `epg_entries:int`, `opacity_expression:string`, `location`, `*_margin_percent`, `width_percent`,
  `text_fit`, `text_align`, `z_index`, `base_style`, `styles:[StyleDefinition]`, `text:string`.
  `StyleDefinition` carries font size/weight/family, color, halo (color/width/blur), spacing.
- **EPG at transcode time**: `GraphicsElementLoader.InitTemplateVariables` →
  `ITemplateDataRepository.GetEpgTemplateData(channelNumber, startTime, epgEntries)` reads the
  **cached** XMLTV fragment (`FileSystemLayout.ChannelGuideCacheFolder/{channelNumber}.xml`) and
  exposes an `Epg` array (`Title`, `SubTitle`, `Description`, `Start`, `Stop`, …). Count is derived
  from the element's `epg_entries`.
- **Selection**: `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs` →
  `SelectGraphicsElements(channel, playoutItem, now)`. Returns empty for
  `StreamingMode.HttpLiveStreamingDirect`; otherwise resolves deco entries (template deco → playout
  deco, each with a `GraphicsElementsMode` of `Merge`/`Override`/`Disable`/`Inherit`) and finally
  falls through to `playoutItem.PlayoutItemGraphicsElements`.
- **Refresh/discovery**: `RefreshGraphicsElementsHandler` scans
  `FileSystemLayout.GraphicsElements*TemplatesFolder` for `*.yml`/`*.yaml`, inserts a
  `GraphicsElement { Path, Kind }` per new file, and **removes rows whose files vanished.** There
  are **no seeded/built-in example elements today** — all are user-supplied.
- **#67 precedent** (`iptv.logo-drives-bug-preset`, `docs/decisions.md`): the "Channel Bug"
  *watermark* is seeded in `DbInitializer.SeedChannelBugWatermark`, guarded by a `ConfigElement`
  marker (`watermark.channel_bug_seeded`), **adopting** any existing row untouched, once per DB.
  The editor toggle reflects a server-provided discriminator (`WatermarkResponseModel.imageSource`),
  **not** a fragile name-match — a lesson we carry over.

Note: watermarks (`ChannelWatermark`, image-only, direct `Channel.WatermarkId` FK) and graphics
elements are **separate parallel systems**. The text EPG overlay must use the graphics-element
system; it cannot reuse the watermark FK (a channel has only one `WatermarkId`, already used by the
#67 logo-bug — reusing it would collide, and multi-line EPG text is a poor fit for the single-image
watermark model). Hence a new channel-level graphics attachment.

## 3. Components / changes

### 3.1 Domain + persistence — `ChannelGraphicsElement` join (NEW)

A 5th join table, structurally identical to the existing four:

```csharp
// ErsatzTV.Core/Domain/ChannelGraphicsElement.cs
public class ChannelGraphicsElement
{
    public int ChannelId { get; set; }
    public Channel Channel { get; set; }
    public int GraphicsElementId { get; set; }
    public GraphicsElement GraphicsElement { get; set; }
}
```

- `Channel` gains `List<ChannelGraphicsElement> ChannelGraphicsElements`.
- `GraphicsElement` gains `List<ChannelGraphicsElement> ChannelGraphicsElements` (+ `List<Channel> Channels` if the existing skip-nav convention is followed — match the `DecoGraphicsElement` config exactly).
- EF configuration mirroring `GraphicsElementConfiguration` / `DecoGraphicsElement` (composite key `{ChannelId, GraphicsElementId}`, cascade on channel delete, restrict/cascade on element delete consistent with the existing joins).
- **Dual-provider migration** via `scripts/add-migration.sh Add_ChannelGraphicsElement` (Sqlite + MySql). Named consistently with the existing `*_Add_DecoGraphicsElements` migrations.

### 3.2 Render-path hook — `GraphicsElementSelector`

Append channel-level elements at the **final fall-through**, alongside
`playoutItem.PlayoutItemGraphicsElements`:

```csharp
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
result.AddRange(channel.ChannelGraphicsElements
    .Map(cge => new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
return result;
```

**Precedence:** channel overlays are a **base layer** — they merge with `Merge`-mode deco elements
and per-playout-item elements. A deco in `Override` or `Disable` mode `return`s before the
fall-through, so it **suppresses** the channel overlay (a deliberate, documented rule: decos can
override channel defaults). `HttpLiveStreamingDirect` continues to return empty early (overlay not
applicable when ErsatzTV isn't transcoding — a documented limitation surfaced in the SPA helper
text).

**Eager-load requirement:** wherever the `Channel` is loaded for the streaming/transcode path
(the query behind `GetPlayoutItemProcessByChannelNumberHandler` →
`FFmpegLibraryProcessService.CreateProcess`), add
`.Include(c => c.ChannelGraphicsElements).ThenInclude(cge => cge.GraphicsElement)` so the selector
sees populated navs. Verify no other caller relies on the channel being loaded without this include
(a missing include yields a silent empty list, not a crash — so an explicit test guards it).

### 3.3 Seeded preset element — `on-now-next.yml` (NEW seed pattern)

- Ship `on-now-next.yml` as an embedded/content resource in the app (a `resources/`-style folder
  copied at build, or an embedded resource written out at seed time).
- A seeder (extend `DbInitializer`, or a dedicated `IGraphicsElementSeeder` it calls) that, guarded
  by a new `ConfigElement` marker `graphics.on_now_next_seeded` (new
  `ConfigElementKey.GraphicsOnNowNextSeeded`):
  1. If the marker is set, do nothing (once per DB — a deleted preset is **not** resurrected,
     matching #67's reasoning that `DbInitializer` runs every startup).
  2. Copy the YAML into `FileSystemLayout.GraphicsElementsTextTemplatesFolder/on-now-next.yml`
     **only if the file is absent** (never overwrite operator edits — adopt-not-clobber).
  3. Ensure a `GraphicsElement` row exists for that path (reuse the row-creation + name-parse logic
     from `RefreshGraphicsElementsHandler`; do not duplicate it — factor a shared helper or invoke
     the refresh so `Name` is parsed from the YAML `name:` field).
  4. Set the marker.
- Idempotency + adopt semantics covered by a test mirroring `DbInitializerChannelBugWatermarkTests`.

**Seed order dependency:** the element row must exist before a channel can reference it. If
`RefreshGraphicsElements` runs only on demand, the seeder must create the row itself (step 3). If it
already runs at startup, ensure the seeder's file-copy precedes it. This ordering is an
implementation checkpoint.

**Seeded YAML (illustrative — finalized during implementation against the loader):**

```yaml
name: On Now / Next
epg_entries: 2
location: BottomLeft
horizontal_margin_percent: 4
vertical_margin_percent: 8
width_percent: 42
text_fit: Wrap
text_align: Left
z_index: 100
# fade in over first ~1s, hold, fade out ~7-8s into the item (contentTime in seconds)
opacity_expression: "<verified against OpacityExpressionHelper during impl>"
base_style: now
styles:
  - name: now
    font_size: 30
    font_weight: 700
    text_color: "#FFFFFF"
    halo_color: "#000000"
    halo_width: 2
  - name: next
    font_size: 22
    font_weight: 400
    text_color: "#DDDDDD"
    halo_color: "#000000"
    halo_width: 2
text: |
  NOW  {{ Epg[0].Title }}
  {{ Epg[0].SubTitle }}
  NEXT {{ Epg[1].Title }} · {{ Epg[1].Start }}
```

The exact `opacity_expression` grammar, the style-span markup that applies `now`/`next` styles to
specific lines, and datetime formatting of `Epg[1].Start` are finalized in implementation by
reading `GraphicsEngine`/`OpacityExpressionHelper`/`TextElement` and the Scriban template helpers —
these are element-authoring details, not architectural unknowns.

### 3.4 API

- `ChannelController` detail + update DTOs gain `graphicsElementIds: int[]` (the channel's attached
  graphics elements), backing the join. Update replaces the set (consistent with existing
  PUT-replace list conventions).
- `GraphicsElementResponseModel` gains a **server-derived `builtIn: bool`**, computed by comparing
  the row's `Path` to the seeded template path — a robust discriminator so the SPA finds the seeded
  "On Now / Next" element without a fragile name-match (the direct #67 lesson). (Alternative if a
  richer taxonomy is wanted later: a `source` enum; `builtIn` is the YAGNI choice now.)
- Regenerate artifacts: build the app project first, then `./scripts/update-openapi.sh`, then
  `npm run generate:api`. This touches `ErsatzTV/Controllers/Api/**` and `ErsatzTV.Core/Api/**` →
  the blocking `api-docs` CI gate requires the regenerated `v1.json`/`v1.d.ts`/`endpoint-index.md`
  in the same diff.

### 3.5 SPA

- `web/src/screens/ChannelEditScreen.tsx`, **Branding tab**, beside the existing "Use logo as
  on-screen bug" switch: add a "Show On Now / Next overlay" `Switch`.
  - On ⇒ add the `builtIn` element's id (found via the existing `GET /api/v1/graphics-elements`,
    filtered by `builtIn === true`) to the channel draft's `graphicsElementIds`.
  - Off ⇒ remove it.
  - Follows the exact pattern of the logo-bug toggle (`logoBugEnabled` / `findLogoBugWatermark`),
    generalized to graphics elements.
- **Scope trim (YAGNI):** no live pixel-preview (the image `BugPreview` renders a static image;
  live EPG-text rendering in the browser is disproportionate). Instead a short static caption
  describing what shows and noting it applies only to transcoded modes (not HLS-Direct).

## 4. Data flow (enable → render)

1. Operator opens Channel editor → Branding → toggles "Show On Now / Next overlay" on → save.
2. `PUT /api/v1/channels/{id}` includes the seeded element id in `graphicsElementIds` → handler
   reconciles the `ChannelGraphicsElement` join set.
3. On stream start, `HlsSessionWorker` → `GetPlayoutItemProcessByChannelNumberHandler` loads the
   channel (with the new include) → `GraphicsElementSelector.SelectGraphicsElements` appends the
   channel overlay element (unless a deco Override/Disable suppresses it).
4. `FFmpegLibraryProcessService.CreateProcess` builds the graphics context;
   `GraphicsElementLoader` parses the YAML, resolves `Epg[0]`/`Epg[1]` from the cached guide, and
   the graphics engine renders the faded text bug into the piped BGRA frames composited by ffmpeg.
5. At the next playout item, a fresh process/context re-resolves NOW/NEXT → the bug updates and
   re-fades.

## 5. Testing / verification

- **NUnit (Core/Infrastructure):**
  - `GraphicsElementSelector` composition: channel overlay merges on the no-deco and `Merge` paths;
    `Override`/`Disable` deco suppresses it; `HttpLiveStreamingDirect` returns empty. (Extends the
    existing selector tests.)
  - Seeder idempotency/adopt: marker gates once-per-DB; existing file/row adopted untouched; deleted
    preset not resurrected (mirror `DbInitializerChannelBugWatermarkTests`).
  - Channel update reconciles the `ChannelGraphicsElement` set (add/remove).
- **Live E2E** (`scripts/e2e-local.sh`, fresh config dir): create a channel with real playout,
  enable the toggle, stream via curl (never a browser tab), and confirm the burned-in NOW/NEXT bug
  renders and changes across a program boundary. Required because this is an API write-path +
  render-path change (`release.live-e2e-required`).
- **Independent review is mandatory** (`process.independent-review-rubric`): the diff touches a DB
  migration, an API write-path handler, and the render path, and exceeds ~150 C# lines. Cross-model
  / cold-context review before merge.

## 6. Risks / open items

- **Guide-cache freshness governs EPG accuracy** — the overlay reads the same cached XMLTV the guide
  serves. Validate on a normal channel. On-demand / time-shifted channels (see #68) may need the
  guide refreshed on thaw for the overlay to match; if E2E shows drift there, file a scoped
  follow-up rather than expanding this PR.
- **HLS-Direct has no overlay** (ErsatzTV isn't transcoding) — documented limitation, surfaced in
  the toggle's caption.
- **Seed file lifecycle** — if an operator deletes the YAML, `RefreshGraphicsElements` removes the
  row and channels silently lose the bug (acceptable, mirrors deleting a preset). The marker keeps
  it from silently reappearing.
- **PR size** — migration + render path + API + seeding + SPA is a sizable single PR; keep slices
  reviewable and land behind the mandatory review.

## 7. Docs to update (same PR)

- `docs/domain-model.md` — channel-level graphics-element attachment (`ChannelGraphicsElement`).
- `docs/api-conventions.md` checklist + regenerated `v1.json` / `endpoint-index.md`
  (`release.api-contract-ci-gate`).
- `docs/decisions.md` — new record(s): channel-level graphics attachment + the seeded
  graphics-element pattern (with `key:`/`Signals:`), cross-referencing `iptv.logo-drives-bug-preset`.
- `docs/channels.md` — the On Now/Next overlay toggle + its transcode-only limitation.
- `docs/spa-conventions.md` — only if the toggle introduces a new pattern (likely not; it reuses the
  logo-bug pattern).

## 8. Out of scope / deferred

- Live pixel-preview of the text overlay in the editor (YAGNI; static caption instead).
- Intra-item text refresh (a program running long won't roll "NEXT" mid-item — text is baked at
  item start, which is fine for the transient-on-transition model).
- Per-channel geometry/content customization of the bug (all channels share the seeded preset;
  operators can edit the YAML or attach a different element — the join is general).
- Overlay on HLS-Direct.
- Periodic/always-on display modes (only transient-on-transition is built).
