The branch moved the `builtIn` discriminator from a bare filename to the full seeded path, but split how the two sites evaluate it: the API handler compares in memory (ordinal) while GetBuiltInElementId's new `.Where(e => e.Path == OnNowNextSeededPath)` compares in SQL. GraphicsElement.Path takes no explicit collation -- TvContext.OnModelCreating pins one only on the listed name/title columns -- so SQLite answers that case-sensitively and MySQL uses the server default, which is normally case-INsensitive. On MySQL the two discriminators could therefore disagree about the same row: AttachOnNowNextByDefault would resolve a case-variant user element as the built-in one while the API reported builtIn:false for it. Collapse both onto GraphicsElementDefaults.IsOnNowNext, ordinal, applied in memory. GetBuiltInElementId goes back to loading the Text candidates and filtering in memory (the shape it had before this branch), keeping only the `Kind` enum filter in SQL. The prose claimed more than the code did. "A filename-only comparison is case-sensitive-by-accident" appeared in four places as a defect the full-path fix removed; a full-path comparison is exactly as case-sensitive, so the clause said nothing and implied a fix that had not happened. Case sensitivity is now deliberate and stated as such -- the built-in element is the exact file the seeder wrote, at the exact path it wrote it to -- and the reason the comparison is kept out of SQL is recorded where the predicate lives. docs/decisions/records/graphics/channel-level-attachment.md said BuiltIn was "computed by comparing the row's `Path` to GraphicsElementDefaults. OnNowNextFileName", which was true of neither the pre-#568 rule (filename to filename) nor the current one; an active record resolved by key now states the current predicate in its own sentence rather than in a parenthetical. Two tests pin the ordinal rule against a loosening to OrdinalIgnoreCase, one per site. Measured: OrdinalIgnoreCase reddens exactly GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn and Ignores_A_Case_Variant_Of_The_Seeded_Path, 2 failed / 77 passed of the 79 graphics tests. They do NOT pin provider independence -- under SQLite's BINARY collation an equivalent SQL comparison answers identically, so no test in this suite can distinguish the two. That is stated at each site rather than left for a reader to assume the tests cover it. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
237 lines
19 KiB
Markdown
237 lines
19 KiB
Markdown
# Graphics elements — schema reference
|
||
|
||
Graphics elements are YAML-authored overlays composited onto a channel's **transcoded** frames by the
|
||
SkiaSharp graphics engine (`ErsatzTV.Infrastructure/Streaming/Graphics/`). They appear in the FFmpeg
|
||
filter graph as the `[vge]` stage.
|
||
|
||
`[vge]` is **not** proof that a graphics element is bound. Watermarks are a separate, older system that
|
||
falls back onto the graphics engine whenever it cannot use ffmpeg's native `overlay` shortcut — an
|
||
`Intermittent`/`OpacityExpression` mode, more than one active watermark, or an external-URL image all
|
||
route a *watermark* through `[vge]` with zero graphics-element rows. The native shortcut is only taken
|
||
for a single `Permanent` local-file watermark
|
||
(`ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs`, `CanUseFFmpegNativeWatermark`).
|
||
|
||
`StreamingMode.HttpLiveStreamingDirect` renders nothing: ErsatzTV is not transcoding, so there is no
|
||
frame pipeline to draw into and `GraphicsElementSelector` returns empty.
|
||
|
||
## Discovery and attachment
|
||
|
||
- Files live under `FileSystemLayout.GraphicsElements*TemplatesFolder` (`text/`, `image/`, `motion/`,
|
||
`subtitle/`, `script/`). `RefreshGraphicsElementsHandler` scans them and maintains one
|
||
`GraphicsElement` row per file, removing rows whose file vanished.
|
||
- Five join tables attach an element: `ChannelGraphicsElement`, `PlayoutItemGraphicsElement`,
|
||
`ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`.
|
||
Resolution order and the deco `Merge`/`Override`/`Disable`/`Inherit` semantics are in
|
||
`graphics.channel-level-attachment`; the channel level is the **base layer**.
|
||
|
||
## Parsing rules that bite
|
||
|
||
- Deserialization is YamlDotNet **without** `IgnoreUnmatchedProperties`, so **an unknown key throws**
|
||
and the whole element is silently disabled for that content with only a logged warning
|
||
(`GraphicsElementLoader.FromYaml<T>`). A typo is not a partial render — it is no render.
|
||
- There is no schema validation (`GraphicsElementLoader` carries an explicit `TODO: validate schema`).
|
||
- Colour strings are parsed with SkiaSharp's `SKColor.TryParse` (`#RRGGBB`, `#AARRGGBB`, named
|
||
colours). Failure handling differs by field and by whether the style is the base one. An unparseable
|
||
**background/border** colour is warned about and that part of the box is skipped. Everywhere else it
|
||
is silent: on the **base** style an unparseable `text_color` falls back to white and an unparseable
|
||
`halo_color` leaves the halo unset; on a **non-base** style an unparseable `text_color` OR
|
||
`halo_color` is simply not applied, so the value inherited from the base style stands.
|
||
- **`font_family` is required on the base style.** Non-base styles inherit it; a null one reaching
|
||
the mapper makes `CustomFontMapper` throw on a null
|
||
dictionary key, which the renderer swallows into "disable for this content" (#570).
|
||
- `{{ … }}` template expressions are rendered by Scriban before the YAML is parsed.
|
||
|
||
## Text element fields
|
||
|
||
Element level (`TextGraphicsElement`):
|
||
|
||
| Field | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `name` | string | Display name; **never** used for identity. |
|
||
| `text` | string | Scriban-templated. `[styleName]…[/styleName]` selects a style for a span. |
|
||
| `base_style` | string | Must name an entry in `styles`, or initialization throws. |
|
||
| `styles` | list | See below. |
|
||
| `epg_entries` | int | How many EPG entries to expose as `Epg[…]`. |
|
||
| `location` | enum | `TopLeft`, `BottomLeft`, … |
|
||
| `horizontal_margin_percent` / `vertical_margin_percent` | double | Percent of frame width/height. |
|
||
| `width_percent` | double | Percent of frame width. A wrapping/scaling **budget** for the whole element, box included — not a hard clamp; see the geometry notes. |
|
||
| `text_fit` | `None` \| `Wrap` \| `Scale` | Only meaningful with `width_percent`. |
|
||
| `text_align` | `Left` \| `Center` \| `Right` | |
|
||
| `z_index` | int | |
|
||
| `opacity_percent` | int | Ignored when `opacity_expression` is set. |
|
||
| `opacity_expression` | string | NCalc expression, e.g. `LinearFadeDuration(content_seconds, 4, 1, 6)`. |
|
||
| `include_fonts_from` | string | Directory of `.ttf`/`.otf` to load. |
|
||
|
||
Background box (#732) — element level, because the engine lays the whole element out as one text block
|
||
and rasterises it into one bitmap, so there is exactly one box per element:
|
||
|
||
| Field | Type | Default | Notes |
|
||
| --- | --- | --- | --- |
|
||
| `background_color` | string | unset | Unset means no **fill**. A `border_color` alone still draws an outlined box; with neither, there is no box and no insets. |
|
||
| `background_opacity_percent` | int | 100 | Clamped to 0–100; multiplies the colour's own alpha, so `#AARRGGBB` and this field compose. |
|
||
| `background_padding` | double px | 0 | Space between the text and the box edge, on all four sides. |
|
||
| `background_corner_radius` | double px | 0 | Clamped to half the shorter side. |
|
||
| `border_color` | string | unset | |
|
||
| `border_width` | double px | 1 when `border_color` is set | A border colour with no width draws a hairline rather than nothing. |
|
||
|
||
Geometry notes:
|
||
|
||
- Padding and border grow the element's bitmap and are subtracted from the text's wrap/scale budget, so
|
||
`width_percent` applies to the visible box rather than to the text inside it. The inset is rounded up
|
||
to a whole pixel once and the same integer is used on both sides, so the box cannot exceed the budget
|
||
by a rounding remainder.
|
||
- **`width_percent` is a budget, not a guarantee.** Wrapping cannot break below a single glyph, so a
|
||
narrow `width_percent` overflows with or without a box — that is pre-existing behaviour, not
|
||
something the box introduced. `text_fit: Scale` shrinks the font instead, down to a 5px floor.
|
||
- If the inset does not fit inside the budget at all, the **inset is clamped** (with a logged warning)
|
||
rather than the text being squeezed to nothing, so an oversized `background_padding` stops growing
|
||
its box at the budget instead of producing one several times the requested width.
|
||
- The inset is **also capped at `min(frameWidth, frameHeight) / 2`**, with a logged warning, whether or
|
||
not `width_percent` is set. Each field is bounded on its own, but padding and border add up, so
|
||
without this a two-field mistake could allocate a bitmap far larger than the frame it is drawn onto.
|
||
- With no box fields set the wrap/scale budget is passed through untouched, so existing overlays keep
|
||
their exact pre-#732 geometry.
|
||
- The box is drawn behind the text and fades with the element, so `opacity_expression` dims box and
|
||
text together.
|
||
- Sizes are in **pixels at frame scale**, matching `font_size`. They do not scale with resolution.
|
||
|
||
Per-style (`StyleDefinition`): `name`, `font_family` (required), `font_size`, `font_weight`,
|
||
`font_italic`, `text_color`, `letter_spacing`, `line_height`, `halo_color`, `halo_width`, `halo_blur`.
|
||
Every one of these overrides the `base_style`'s value when the style declares it — the halo fields
|
||
did not until #732, so before that a non-base style silently rendered the base style's halo. The
|
||
seeded `on-now-next.yml` is unaffected because all three of its styles declare the same halo: its
|
||
rendered bitmap is byte-identical either way. That invariant holds on any host; a specific hash would
|
||
not, because the render depends on the host's fonts (see `docs/testing.md`). Re-derive it by rendering
|
||
`OnNowNextYaml` through `TextElement` at 1920x1080 with and without the per-style halo merge and
|
||
comparing the encoded PNGs — they must be equal, whatever their hash is.
|
||
|
||
**This is an operator-visible change and belongs in the release notes.** A custom template whose
|
||
non-base style declares its own `halo_color` / `halo_width` / `halo_blur` renders differently after
|
||
this version — correctly, but differently. So does one using `line_height` with `text_fit: Scale`:
|
||
the Scale path now carries line height through, which changes measured height and therefore where the
|
||
element anchors.
|
||
Halo is drawn by RichTextKit as part of the text, not by the box code — a wide halo **on top of** a
|
||
background box over-darkens the glyphs, which is why the seeded template cuts it to 1px.
|
||
|
||
## The built-in On Now / Next element
|
||
|
||
`GraphicsElementSeeder` writes `on-now-next.yml` once per database (`graphics.on_now_next_seeded`) and
|
||
never overwrites an operator's file. Two rules govern it after that:
|
||
|
||
- Updating the shipped default → `graphics.seeded-template-upgrade-by-fingerprint`.
|
||
- It is attached to channels by default → `graphics.on-now-next-on-by-default`.
|
||
|
||
Identity is the **full seeded path**, never the editable `Name` and never the bare filename
|
||
(`OnNowNextFileName`) alone — a filename-only, folder-agnostic comparison let a user element named
|
||
exactly `on-now-next.yml` in a different template folder also report `builtIn:true` (#568). Both
|
||
`GraphicsElementResponseModel.BuiltIn` and `GraphicsElementSeeder.GetBuiltInElementId` resolve it
|
||
through the one predicate, `GraphicsElementDefaults.IsOnNowNext`, so the SPA never name-matches.
|
||
|
||
`IsOnNowNext` is **ordinal**, and every caller applies it **in memory** rather than in a `Where`
|
||
clause. That is not incidental: `GraphicsElement.Path` takes no explicit collation (`TvContext`
|
||
pins one only on the listed name/title columns), so a SQL `Path ==` comparison is case-sensitive
|
||
under SQLite and normally case-INsensitive under MySQL. Evaluating one of the two discriminator
|
||
sites in SQL and the other in memory is what would let them disagree, on MySQL only. The SQLite test
|
||
suite cannot distinguish the two — BINARY collation and an ordinal comparison agree on every input —
|
||
so this is held by keeping the comparison out of SQL, not by a test; what the case-variant tests in
|
||
the table below pin is the ordinal rule itself, against a loosening to `OrdinalIgnoreCase`.
|
||
|
||
## Tests
|
||
|
||
`ErsatzTV.Tests/Infrastructure/Graphics/TextElementBackgroundBoxTests.cs` renders real bitmaps and
|
||
asserts pixels — the box's failure mode is a silent no-op, which a model-level test cannot see.
|
||
Geometry assertions are relative to a no-box baseline so they do not depend on the host's typeface,
|
||
and `Baseline_Renders_A_Non_Empty_Bitmap` turns a fontless host into a loud red instead of a suite
|
||
that passes vacuously on a 0x0 bitmap (see `docs/testing.md` → font dependency).
|
||
|
||
Attachment behaviour is covered by `GraphicsElementDefaultAttachTests`,
|
||
`GraphicsElementSeederUpgradeTests` and `CreateChannelDefaultGraphicsElementTests`.
|
||
|
||
### Mutation record (#732)
|
||
|
||
Every clause below was mutated in turn against this tree and **the whole `ErsatzTV.Tests` project**
|
||
re-run; the "Reddens" column is the measured failure set. The working tree was confirmed clean after
|
||
each run. Reproduce one by making the edit and running
|
||
`dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj` — **not** a `--filter`ed run.
|
||
|
||
Three traps this table is built to avoid:
|
||
|
||
- **Measure against the whole project, never a per-fixture filter.** A filtered run structurally
|
||
cannot observe a red in another fixture, so it under-reports the failure set while looking precise.
|
||
Thirteen rows below redden more than one test, and two of them span two fixture classes — those two
|
||
are exactly what a per-fixture filter cannot see.
|
||
- **A mutation that fails to COMPILE is not a result.** Warnings-as-error turn the obvious mutation
|
||
shapes into build failures — `CS0162` for `if (true) { return; }`, `CS1717` for self-assignment,
|
||
Sonar `S3981` for a constant-folded condition — and a build failure emits no test summary at all,
|
||
which reads exactly like a pass in a scrolled log. Require a summary line, and detect build failure
|
||
with `: error `, not `error CS`.
|
||
- **Re-measure the red set; never carry it over.** Test renames and added cases both change which
|
||
tests a clause reddens, so a row written from anything but a fresh run describes a tree that no
|
||
longer exists.
|
||
|
||
**This table is not an enumeration of the change.** It lists the clauses that have a measured proof;
|
||
it makes no claim about the ones that do not appear. A clause absent from it has no proof — which is a
|
||
reason to go and check, never evidence that none is possible. A "the only uncovered clause is X"
|
||
sentence was tried here and abandoned: it was falsified three times, each time by a different clause,
|
||
because it silently becomes false the moment a guard is added without its row.
|
||
|
||
Known clauses with no red, as examples rather than a closed set. Each was measured, and each is
|
||
recorded with why a test could not reach it — an unreachable clause is a reason to say so, not to ship
|
||
a test that cannot fail:
|
||
|
||
- the `float.IsFinite` guard on the `width_percent` budget — .NET saturates float→int conversion, so
|
||
removing it changes nothing observable;
|
||
- the `insetPixels == 0 ? maxWidth : …` compatibility branch — a `width_percent` that rounds to zero
|
||
renders identically whether RichTextKit is given a `MaxWidth` of 0 or 1;
|
||
- the absent-target early return and the temp-file existence check in `UpgradeUnmodifiedTemplate` —
|
||
behaviourally redundant; the first re-raises as a caught `IOException`, the second guards a delete
|
||
that is already a no-op;
|
||
- the inner `catch` around that cleanup delete — it stops a failing delete from replacing the
|
||
exception being unwound (which C# otherwise does, and which would downgrade a real cancellation to a
|
||
swallowed warning). `MockFileSystem` does not propagate an interceptor throw raised during the
|
||
delete, so the clause is correct by language semantics but not reachable from a test here;
|
||
- the `Kind == GraphicsElementKind.Text` filter in `GetBuiltInElementId` (#568) — belt-and-braces
|
||
since `OnNowNextSeededPath` is itself a text-template-folder path, so the `Path` equality clause
|
||
above (row 10) always excludes a wrong-`Kind` row first. Measured 2026-09-05: dropping the `Kind`
|
||
filter alone and running every test that calls `GetBuiltInElementId` or `ChannelGraphicsDefaults`
|
||
(`GraphicsElementDefaultAttachTests`, 11 tests, the only file referencing either), nothing reddens —
|
||
`Ignores_A_Same_Named_Element_Of_A_Different_Kind` included, because that test's element path never
|
||
equals the seeded path regardless of `Kind`.
|
||
|
||
Rows are written per clause, not per block: a row naming a whole `if` or a whole style-merge block
|
||
hides which individual fields inside it are actually reachable from a test.
|
||
|
||
| # | Clause mutated | Reddens (measured) |
|
||
|---|---|---|
|
||
| 1 | DrawBackgroundBox returns immediately | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element`<br>`Background_Color_Fills_The_Box`<br>`Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha`<br>`Background_Padding_Actually_Insets_The_Text`<br>`Border_Color_Draws_A_Border_Distinct_From_The_Fill`<br>`Border_Color_Without_An_Explicit_Width_Draws_A_Hairline`<br>`Corner_Radius_Rounds_The_Corner_Away` |
|
||
| 2 | inset not subtracted from the wrap budget | `Fractional_Padding_Still_Respects_Width_Percent`<br>`Width_Percent_Bounds_The_Whole_Box_Including_Padding` |
|
||
| 3 | text painted at (0,0) instead of the inset | `Background_Padding_Actually_Insets_The_Text` |
|
||
| 4 | ApplyOpacityPercent returns the colour unchanged | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha` |
|
||
| 5 | UpgradeUnmodifiedTemplate call removed | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact`<br>`An_Unwritable_Template_Does_Not_Fail_Startup`<br>`The_Upgraded_Template_Still_Deserializes_With_A_Resolvable_Base_Style`<br>`Upgrades_An_Untouched_Previous_Default`<br>`Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
|
||
| 6 | fingerprint check ignored (upgrade unconditionally) | `Leaves_An_Operator_Modified_File_Alone` |
|
||
| 7 | line-ending normalisation dropped from Normalize | `Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
|
||
| 8 | HLS-Direct exclusion removed from the backfill | `Skips_Hls_Direct_Channels_Where_The_Overlay_Cannot_Render` |
|
||
| 9 | already-attached filter removed | `Does_Not_Duplicate_An_Existing_Attachment`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
|
||
| 10 | seeded-path check removed from the built-in lookup (#568: `Path == OnNowNextSeededPath`, the full path, not a bare-filename match) | `Ignores_A_Non_Built_In_Element_With_A_Different_Filename`<br>`Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder` |
|
||
| 11 | graphics.on_now_next_default_attached guard never fires | `Does_Not_Re_Attach_After_An_Operator_Clears_It` |
|
||
| 12 | create-time `ChannelGraphicsDefaults.Attach` call removed from `CreateChannelHandler` | `Attaches_The_Built_In_Element_To_A_New_Channel` |
|
||
| 13 | HLS-Direct skip removed from the create path | `Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There`<br>`Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel` |
|
||
| 14 | upgrade no longer catches filesystem exceptions | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact`<br>`A_Read_Failure_On_The_Template_Does_Not_Fail_Startup`<br>`An_Unwritable_Template_Does_Not_Fail_Startup` |
|
||
| 15 | FitTextBlock drops HaloColor again | `The_Scale_Path_Preserves_Halo_Blur`<br>`The_Scale_Path_Preserves_The_Halo` |
|
||
| 16 | unrounded inset subtracted from the budget | `Fractional_Padding_Still_Respects_Width_Percent` |
|
||
| 17 | inset clamp removed | `An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits` |
|
||
| 19 | Sanitize passes values through unchecked | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry` |
|
||
| 20 | the BOX itself is no longer clamped (only insetPixels) | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element` |
|
||
| 21 | duplicate guard removed from EnsureBuiltInElementRow | `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
|
||
| 22 | EnsureBuiltInElementRow removed from the ALREADY-SEEDED branch | `An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One`<br>`Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
|
||
| 23 | non-atomic in-place write restored | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact` |
|
||
| 24 | marker written even with nothing resolved | `Stays_Armed_When_There_Is_No_Built_In_Element_To_Attach`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
|
||
| 25 | the atomic replace becomes a non-atomic copy | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact` |
|
||
| 26 | the frame cap on the inset is disarmed | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry`<br>`The_Inset_Is_Capped_Against_The_Frame_Even_With_No_Width_Percent` |
|
||
| 27 | per-style halo overrides dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur`<br>`A_Non_Base_Style_Uses_Its_Own_Halo_Not_The_Base_Styles` |
|
||
| 28 | FitTextBlock drops HaloBlur from the rebuilt style | `The_Scale_Path_Preserves_Halo_Blur` |
|
||
| 29 | FitTextBlock drops LineHeight from the rebuilt style | `The_Scale_Path_Preserves_Line_Height` |
|
||
| 30 | per-style halo_blur override dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur` |
|
||
| 31 | the 0-100 opacity clamp is removed | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range` |
|
||
| 32 | the lineup create path stops attaching the built-in element | `Should_Attach_The_Built_In_On_Now_Next_Element` |
|