Files
ersatztv/docs/graphics-elements.md
T
timothyandClaude Fable 5.1 41fd1f64de docs(568): re-measure the rows the seeder's lookup call moved, and recount the table's own claims
The seeder now resolves the built-in row through GetBuiltInElementId, which puts
that lookup on a second call path, so every row whose clause the new call can
reach was re-run against this tree: 10 and 33 unchanged, 18 reinstated (the
Kind == Text filter has a red now that a wrong-kind row at the seeded path can
suppress the row the lookup needs), 21 unchanged, 22 gains a third red, and 34
is new (the existence check re-derived as SQL instead of asking the lookup).

Two stale measurements went with it. The "known clauses with no red" bullet for
the Kind filter quoted 2121 passed against a tree that produces 2123, having been
taken before the branch's last two tests existed -- the whole bullet is gone now
that the clause has a red. And the per-fixture-filter trap counted thirteen
multi-test rows with two spanning two fixture classes, true on origin/main and
false here since the branch added rows: fifteen and three, both recounted from
the table, with a note that they are.

Decisions-Edit: yes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 21:35:15 +02:00

21 KiB
Raw Blame History

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

The seeder's own "does this row exist yet?" test — EnsureBuiltInElementRow, which decides whether to create the row at startup — does not re-derive that predicate either: it calls GetBuiltInElementId, so the question asked at startup is the question every consumer asks afterwards. As its own SQL Path == target comparison it could answer differently in two ways, each leaving the built-in element undiscoverable for the life of the install (#568): the collation one below, and Kind, which the SQL check ignored while the lookup requires it. That second one is what makes the Kind == Text filter in the lookup load-bearing rather than belt-and-braces (row 18). Creating the row stays idempotent because the path it writes is the path the lookup matches — held by Repeated_Seeding_Does_Not_Accumulate_Element_Rows (row 21), which reddens if the two drift.

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 discriminator site in SQL and another 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.csprojnot a --filtered 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. Fifteen rows below redden more than one test, and three of them span two fixture classes — those three are exactly what a per-fixture filter cannot see. Both figures are counted from the table itself, so a row added without recounting them makes this bullet quietly false — recount both whenever a row is added or its red set changes.
  • 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.

A row number is an identity, not a position: prose elsewhere cites rows by number (the built-in element section above cites rows 18 and 21), so a new row takes the next unused number and the rest are never renumbered. A clause that loses its red keeps its number vacant rather than freeing it for some other clause — reusing it would silently re-point every existing citation. Reinstating the SAME clause under its OWN number is the sole exception, because that is the same identity and not a new one: row 18 is the Kind == Text filter, and stays that clause whether or not it currently has a red.

# Clause mutated Reddens (measured)
1 DrawBackgroundBox returns immediately An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element
Background_Color_Fills_The_Box
Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range
Background_Opacity_Percent_Scales_The_Alpha
Background_Padding_Actually_Insets_The_Text
Border_Color_Draws_A_Border_Distinct_From_The_Fill
Border_Color_Without_An_Explicit_Width_Draws_A_Hairline
Corner_Radius_Rounds_The_Corner_Away
2 inset not subtracted from the wrap budget Fractional_Padding_Still_Respects_Width_Percent
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
Background_Opacity_Percent_Scales_The_Alpha
5 UpgradeUnmodifiedTemplate call removed A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact
A_Failed_Write_Leaves_The_Original_Template_Intact
An_Unwritable_Template_Does_Not_Fail_Startup
The_Upgraded_Template_Still_Deserializes_With_A_Resolvable_Base_Style
Upgrades_An_Untouched_Previous_Default
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
While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel
10 seeded-path check removed from the built-in lookup (#568: the IsOnNowNext filter, the full path, not a bare-filename match) Ignores_A_Case_Variant_Of_The_Seeded_Path
Ignores_A_Non_Built_In_Element_With_A_Different_Filename
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
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
A_Failed_Write_Leaves_The_Original_Template_Intact
A_Read_Failure_On_The_Template_Does_Not_Fail_Startup
An_Unwritable_Template_Does_Not_Fail_Startup
15 FitTextBlock drops HaloColor again The_Scale_Path_Preserves_Halo_Blur
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 == GraphicsElementKind.Text filter dropped from the built-in lookup (#568) — the lookup is what EnsureBuiltInElementRow asks, so a wrong-kind row at the seeded path would answer for the Text row that never then gets created A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row
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 A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row
An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One
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
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
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
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
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
33 IsOnNowNext loosened from Ordinal to OrdinalIgnoreCase (#568) — one row for both discriminator sites, since they share the predicate GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn
Ignores_A_Case_Variant_Of_The_Seeded_Path
34 EnsureBuiltInElementRow's existence check re-derived as a SQL AnyAsync(e => e.Path == target) instead of asking GetBuiltInElementId (#568) A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row