Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
219 lines
17 KiB
Markdown
219 lines
17 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 **filename** (`GraphicsElementDefaults.OnNowNextFileName`), never the editable `Name`;
|
||
`GraphicsElementResponseModel.BuiltIn` is derived from it server-side so the SPA never name-matches.
|
||
|
||
## 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.
|
||
|
||
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 | filename check removed from the built-in lookup | `Ignores_A_Non_Built_In_Element_With_A_Different_Filename` |
|
||
| 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` |
|
||
| 18 | Kind == Text filter dropped from the lookup | `Ignores_A_Same_Named_Element_Of_A_Different_Kind` |
|
||
| 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` |
|