tituli

tituli — text in video: title cards, credits, captions and calligrams.

One model underneath: a Layout of placed Run`s on a :class:`Frame that knows as much (or as little) about the picture as you tell it. Everything else is a convenience over that.

Quick start:

from tituli import Frame, title_card, caption, credits_crawl, on_path, render

frame = Frame.blank((1920, 1080), color="#101014")
render(title_card("Il pleut", "Apollinaire, 1918", frame=frame), frame).save("title.png")

frame = Frame.from_image("still.jpg", delivery="youtube")   # add avoid=burns.salient_box
render(caption("Eliza Hamilton", "Ralph Earl, 1787 · public domain", frame=frame), frame).save("cap.png")

Optional layers, none imported here: tituli[shaping] (HarfBuzz engine), tituli[saliency] (burns subject avoidance), tituli[lacing] (the text-overlay body schema), tituli[cli] (python -m tituli).

class tituli.Box(x0: float, y0: float, x1: float, y1: float)[source]

A pixel-space rectangle (x0, y0, x1, y1); edges are floats.

classmethod from_norm(nb: tuple[float, float, float, float], width: float, height: float) Box[source]

Scale a normalised (x, y, w, h) into a frame of that size.

classmethod from_size(x: float, y: float, w: float, h: float) Box[source]

Build from an origin and a size.

inset(dx: float, dy: float | None = None) Box[source]

Shrink by dx horizontally and dy (default dx) vertically.

overlap_fraction(other: Box) float[source]

Fraction of this box’s area covered by other.

>>> Box(0, 0, 10, 10).overlap_fraction(Box(5, 0, 20, 10))
0.5
to_norm(width: float, height: float) tuple[float, float, float, float][source]

The inverse of from_norm().

class tituli.Credits(sections: tuple[~tituli.credits.Section, ...]=(), title: str = '', closing: tuple[str, ...]=(), meta: dict = <factory>)[source]

The whole roll.

classmethod from_dict(d: dict[str, Any]) Credits[source]

Build from {"title", "sections": [{"heading", "entries", "kind"}], "closing"}.

The caller owns making the text presentable: tituli sets whatever it is given, so a raw manifest — camera filenames as roles, "Unknown authorUnknown author" from a concatenated field — comes out as a designed card of raw manifest strings. Map titles, artists and licences into readable roles and names first.

classmethod from_lines(lines: Iterable[str], *, heading: str = 'Credits', title: str = '') Credits[source]

The plain-list case (what braidio.video.credits_card takes).

Each line is set as prose, verbatim. Lines built from a fetch manifest ("<file> <author> <licence>") read as a terminal dump however well they are typeset; prefer from_dict() with {"role", "name"} entries (“Portrait of Eliza Hamilton”, “Ralph Earl, 1787 · public domain”).

class tituli.CreditsStyle(title: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0), heading: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75), role: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8), name: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0), line: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0), ink: Any = (255, 255, 255), background: Any = (8, 8, 10))[source]

The type ramp for a roll. One object so a film is one design.

class tituli.Entry(name: str, role: str = '')[source]

One credit line: role + name, a bare name, or prose.

class tituli.Face(family: str, style: str, size: int, path: str | None, index: int = 0)[source]

A resolved, sized font ready to measure and draw with Pillow.

property ascent: int

Ascent in pixels.

property descent: int

Descent in pixels.

length(text: str) float[source]

Advance width of text in pixels.

property line_height: int

Ascent + descent.

property pil: FreeTypeFont

The Pillow font object (cached per (path, index, size)).

with_size(size: int) Face[source]

Same face at another pixel size.

class tituli.Frame(width: int, height: int, color: tuple[int, int, int, int] | None = None, image: Image | None = None, avoid: tuple[Box, ...] = (), reserved: tuple[Box, ...] = (), samples: tuple[Image, ...] = ())[source]

Size plus whatever else is known about the picture text will sit on.

classmethod blank(size: tuple[int, int] = (1920, 1080), *, color: Any = None) Frame[source]

Rung (a) when color is None, rung (b) when it is given.

classmethod from_image(image: Image | str | Path | Sequence[Image | str | Path], *, avoid: None | Iterable[tuple[float, float, float, float]] | Callable[[Any], Any] = None, size: tuple[int, int] | None = None, delivery: str | None = None) Frame[source]

Rung (c), and rung (d) when avoid is given.

image may also be a sequence of frames (the stills a camera move will pass through under the text): the first is what gets composited, all of them feed luminance_stats(), which then reports the worst instant — so a scrim is as light as the whole window allows, not as heavy as one frame demands.

avoid is normalised boxes, or a callable taking the PIL image and returning one box or several (burns.salient_box fits directly). It runs on every sampled frame. size cover-fits the picture(s) to the frame first. delivery names a target whose reserved zones ("youtube": subtitle band + control bar) placement must keep clear.

luminance_stats(box: Box) tuple[float, float] | None[source]

(mean, stddev) of relative luminance under box; None if unknown.

A high stddev means a busy patch — even a well-contrasted mean will not keep every glyph readable, which is when a scrim earns its place.

luminance_under(box: Box) float | None[source]

Mean relative luminance of the pixels under box; None if unknown.

classmethod over(stills: Sequence[Image | str | Path], *, avoid: None | Iterable[tuple[float, float, float, float]] | Callable[[Any], Any] = None, size: tuple[int, int] | None = None, delivery: str | None = None) Frame[source]

The frame a caption will sit on over a time window of stills.

The named entry point for the sequence case: the first still is what gets composited, all of them are sampled, and luminance_stats reports the worst instant — so a scrim over a Ken Burns move is as light as the whole window allows. (from_image accepts a sequence too; this name says what it is for.)

overlap(box: Box) float[source]

Fraction of box that covers something in avoid (0 when nothing).

place(size: tuple[float, float], *, anchor: str | Sequence[str] = 'auto', region: Box | None = None) tuple[str, Box][source]

Where to put a block of size: a named anchor and its pixel box.

anchor may be one name, a preference list, or "auto" (the default order). With rung (d) knowledge the candidate that least covers the subject wins; ties fall to preference order.

>>> f = Frame.blank((1000, 500))
>>> f.place((200, 50), anchor="bottom")[0]
'bottom'
property safe: Box

The title-safe box (SMPTE ST 2046-1, 90 %).

with_avoid(avoid: None | Iterable[tuple[float, float, float, float]] | Callable[[Any], Any]) Frame[source]

Same frame with (additional) regions to keep clear.

with_delivery(delivery: str | None) Frame[source]

Same frame, placement keeping clear of that target’s reserved zones.

class tituli.Label(text: str, attribution: str = '', key: str | None = None)[source]

What goes on a museum label: the thing, and where it came from.

class tituli.Layout(runs: tuple[~tituli.layout.Run, ...]=(), plates: tuple[~tituli.layout.Plate, ...]=(), meta: dict = <factory>)[source]

Everything the renderer needs: runs on top of plates, in frame pixels.

bbox() Box[source]

Bounds of the runs (plates excluded).

property duration_hint: float

Latest t_full among the runs (0 when untimed).

moved_to(box: Box) Layout[source]

Translate so the runs’ bbox top-left lands on box’s top-left.

staggered(*, start: float = 0.0, step: float, ramp: float) Layout[source]

Reveal runs one after another: run i starts at start + i*step.

with_timing(t_in: float, t_full: float) Layout[source]

Give every run the same reveal envelope.

class tituli.Path(points: tuple[tuple[float, float], ...])[source]

A polyline with an arc-length parameterisation.

Build one with the constructors (line(), arc(), wave(), bezier(), polyline(), from_svg(), from_function()) and query it by distance along the curve: point(s) and angle(s) (degrees, clockwise-positive on screen because y points down).

angle(s: float) float[source]

Tangent direction at s in degrees (0 = rightwards, 90 = down).

classmethod arc(center: tuple[float, float], radius: float, start_deg: float, end_deg: float, *, samples: int = 512) Path[source]

Circular arc; angles in degrees, 0 = right, 90 = down (screen coords).

classmethod bezier(*controls: tuple[float, float], samples: int = 512) Path[source]

A Bézier curve of any degree through controls (De Casteljau).

classmethod circle(center: tuple[float, float], radius: float, *, start_deg: float = -90.0) Path[source]

A full circle starting at the top by default, running clockwise.

property closed: bool

Whether the path ends where it starts (a circle, a closed SVG path).

>>> Path.circle((0, 0), 10).closed, Path.line((0, 0), (1, 1)).closed
(True, False)
fit(box: Box, *, keep_aspect: bool = True) Path[source]

Scale and translate so the path’s bounding box fills box.

Degenerate axes (a horizontal line has zero height) are centred rather than scaled.

classmethod from_function(fn: Callable[[float], tuple[float, float]], *, samples: int = 512) Path[source]

Sample fn(t) for t in [0, 1].

classmethod from_svg(d: str) Path[source]

Parse an SVG path d string (M L H V C S Q T Z, absolute or relative).

Arcs (A) are not supported — approximate them with a Bézier.

>>> Path.from_svg("M 0 0 L 10 0 L 10 10").length
20.0
point(s: float) tuple[float, float][source]

Position at arc length s (clamped to the path).

classmethod wave(start: tuple[float, float], end: tuple[float, float], *, amplitude: float, cycles: float = 1.0, samples: int = 512) Path[source]

A sine wave along the segment start -> end.

class tituli.Plate(box: Box, color: tuple[int, int, int, int], kind: Literal['box', 'gradient-bottom', 'gradient-top', 'corner-top-left', 'corner-top-right', 'corner-bottom-left', 'corner-bottom-right'] = 'box', radius: float = 0.0, solid: tuple[float, float] = (0.5, 0.5))[source]

A backdrop drawn under the runs: a scrim, a box, a rule.

class tituli.Run(text: str, x: float, y: float, face: Face, color: tuple[int, int, int, int], angle: float = 0.0, opacity: float = 1.0, tracking: float = 0.0, unit: Literal['line', 'word', 'glyph'] = 'line', index: int = 0, t_in: float | None = None, t_full: float | None = None, tags: tuple[str, ...] = ())[source]

A string drawn from a baseline origin, possibly rotated.

bbox() Box[source]

Axis-aligned bounds (ignores rotation — fine for placement).

class tituli.Section(heading: str = '', entries: tuple[Entry, ...] = (), kind: str = 'auto')[source]

A heading over a run of entries. kind picks the treatment:

"pairs" role/name on a gutter (cast, crew); "list" centred names; "prose" wrapped small text (licences, thanks).

class tituli.Span(start: float, end: float, key: str, data: Any = None)[source]

A time range of the cut showing one picture. key identifies the picture.

(Named Span rather than Panel so it never collides with braidio.video.Panel, which callers of both will import alongside it.)

class tituli.TextStyle(family: Sequence[str] = ('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size: float = 0.04, weight: int = 400, italic: bool = False, condensed: bool = False, color: tuple[int, int, int] | tuple[int, int, int, int] | str = (255, 255, 255), tracking: float = 0.0, leading: float = 1.2, case: Literal['as-is', 'upper', 'lower', 'title'] = 'as-is', align: Literal['left', 'center', 'right'] = 'center', opacity: float = 1.0)[source]

How a run of text looks. All fields keyword-only; build variants with with_.

face(frame_height: float) Face[source]

The resolved, sized font for this style.

px(frame_height: float) int[source]

Font size in pixels for a frame of that height.

with_(**changes: Any) TextStyle[source]

A copy with some fields replaced.

>>> TextStyle().with_(weight=700).weight
700
class tituli.TimedOverlay(layout: Layout | None, start: float, end: float, slot: str = 'top-left', weight: int = 1, fade: float = 0.45, payload: Any = None, meta: dict = <factory>)[source]

A layout (or a thing to lay out) on screen from start to end.

slot names the region it occupies; weight orders overlays that contend for a slot — the heavier suppresses the lighter, never stacks. payload is whatever the caller wants to carry to rendering (a Label, a dict, …); layout is filled in once rendered.

tituli.along_path(text: str, path: Path, style: TextStyle, frame: Frame | float, *, start: float = 0.0, align: Literal['start', 'center', 'end'] = 'start', offset: float = 0.0, upright: bool = False, color: tuple[int, int, int, int] | None = None, tags: tuple[str, ...] = ()) Layout[source]

Set text glyph by glyph along path (the path is the baseline).

Each glyph’s origin is the point at its arc length and it is rotated to the tangent there — unless upright (a calligram convention: letters stay vertical while their positions follow the shape). offset shifts the baseline perpendicular to the path (negative = above, in screen terms). align positions the whole string on the path from start.

Text longer than the path keeps its spacing and runs off the end — that is reported in meta["overflow"] rather than silently squeezed.

tituli.block(text: str | Sequence[str], style: TextStyle, frame: Frame | float, *, max_width: float | None = None, x: float = 0.0, y: float = 0.0, color: tuple[int, int, int, int] | None = None, unit: Literal['line', 'word', 'glyph'] = 'line', tags: tuple[str, ...] = ()) Layout[source]

Lay out prose as lines from the top-left corner (x, y).

text is a string (wrapped to max_width when given) or pre-broken lines. Alignment follows style.align within max_width (or the widest line when no width is given). unit="glyph" emits one run per character (needed for tracking and for per-glyph reveals).

tituli.caption(text: str, attribution: str = '', *, frame: Frame, anchor: str | Sequence[str] = 'auto', style: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.04, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.25, case='as-is', align='left', opacity=1.0), attribution_style: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.02, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='left', opacity=0.85), max_lines: int = 3, max_width: float = 0.55, ink: tuple[int, int, int, int] | None = None, scrim: bool | None = None, accent: bool = True) Layout[source]

A museum label over a picture: what is on screen, plus a tiny credit line.

text is wrapped to max_width of the safe box and hard-truncated at max_lines; attribution is set small and slightly transparent — present enough to credit, small enough not to compete. Placement avoids the frame’s avoid boxes and its delivery target’s reserved zones; the scrim is a 2-D corner falloff cut to the measured block.

tituli.contrast_ratio(a: tuple[int, int, int] | tuple[int, int, int, int] | str, b: tuple[int, int, int] | tuple[int, int, int, int] | str) float[source]

WCAG contrast ratio between two colours (1..21).

>>> round(contrast_ratio("#777", "#fff"), 2)
4.48
tituli.cover_fit(img: Image, size: tuple[int, int]) Image[source]

Scale img to fill size and crop the overflow, centred.

>>> cover_fit(Image.new("RGB", (100, 50)), (50, 50)).size
(50, 50)
tituli.credits_cards(credits: Credits, *, frame: Frame, style: CreditsStyle = CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0), ink=(255, 255, 255), background=(8, 8, 10)), max_cards: int | None = None) list[Layout][source]

Paginate the roll into cards that each fit the safe area, centred.

A heading is never orphaned at the bottom of a card. Raises when max_cards would force truncation — losing an attribution silently is a licence failure invisible to the person responsible for it.

tituli.credits_crawl(credits: Credits, *, frame: Frame, style: CreditsStyle = CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0), ink=(255, 255, 255), background=(8, 8, 10))) tuple[Layout, int][source]

One tall layout and its total height in pixels (for tituli.video.crawl()).

The layout starts one frame-height down and ends one frame-height before its bottom, so the crawl enters from an empty frame and leaves to one.

tituli.credits_frame(size: tuple[int, int], style: CreditsStyle = CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0), ink=(255, 255, 255), background=(8, 8, 10))) Frame[source]

The canonical credits frame: near-black, so ink defaults to white.

tituli.decide_ink(frame: Frame, box: Box, *, preferred: tuple[int, int, int, int] | None = None) InkDecision[source]

Choose ink and whether a scrim is needed under box.

Rung (a) (nothing known): white on a dark scrim, the universal default. Rung (b)/(c): the ink with more contrast; a scrim only when contrast is still short of WCAG 4.5:1 or the patch is busy. preferred is honoured when it already has enough contrast.

tituli.families() list[str][source]

Sorted family names installed on this machine.

tituli.find_font(family: str | Sequence[str], *, weight: int = 400, italic: bool = False, condensed: bool = False) FontFile | None[source]

First installed family in the preference list, at the closest style.

family may be one name or a preference list. A path to a font file is accepted too, and wins outright.

>>> find_font("definitely-not-installed-xyz") is None
True
tituli.fit_size(text: str, style: TextStyle, frame_height: float, *, max_width: float, max_height: float | None = None, min_size: float = 0.012, step: float = 0.9) TextStyle[source]

Shrink style.size until text wraps within the given bounds.

Shrinks geometrically by step and never below min_size (a fraction of frame height, like size itself).

tituli.frames(layout: Layout, frame: Frame, *, duration: float, fps: float = 30.0, engine: Engine | None = None, hold_after: float = 0.0) Iterator[Image][source]

Yield one image per video frame, honouring the runs’ reveal envelopes.

duration is the whole clip; the last reveal completes at layout.duration_hint and the layout then holds. Frames are rendered lazily so a long clip never sits in memory at once.

tituli.in_shape(text: str, mask, *, frame: Frame, style: TextStyle = TextStyle(family=('Georgia', 'Palatino', 'Baskerville', 'Liberation Serif', 'DejaVu Serif', 'Times New Roman'), size=0.035, weight=400, italic=False, condensed=False, color=(17, 17, 17), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0), box: Box | None = None, repeat: bool = True) Layout[source]

Pour prose into a silhouette (light = inside). See tituli.layout.fill_shape().

tituli.ink_for(*, luminance: float, light: tuple[int, int, int] = (255, 255, 255), dark: tuple[int, int, int] = (17, 17, 17)) tuple[int, int, int][source]

Pick the ink (light or dark) with more contrast against luminance.

tituli.intertitle(text: str, *, frame: Frame, style: TextStyle | None = None) Layout[source]

A silent-film style card: serif italic prose, centred on the frame.

tituli.lower_third(name: str, role: str = '', *, frame: Frame, anchor: str = 'bottom-left', name_style: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.042, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0), role_style: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.04, leading=1.2, case='as-is', align='left', opacity=0.9), ink: tuple[int, int, int, int] | None = None, scrim: bool | None = None) Layout[source]

Who is speaking: a name and a role, left-anchored, scrimmed if needed.

Respects the frame’s delivery zones — with delivery="youtube" the block moves above the subtitle band rather than into it.

tituli.make_engine(name: str = 'pillow') Engine[source]

Resolve an engine by name: "pillow" (default) or "harfbuzz".

tituli.measure(text: str, style: TextStyle, frame_height: float) float[source]

Width in pixels of text set in style on a frame of that height.

tituli.note(lines: Sequence[str] | str, *, frame: Frame, headline: str = '', anchor: str | Sequence[str] = 'top-left', headline_style: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.055, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.15, case='as-is', align='left', opacity=1.0), line_style: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='left', opacity=0.92), max_width: float = 0.62, ink: tuple[int, int, int, int] | None = None, scrim: bool | None = None, accent: bool = True) Layout[source]

An editorial context card: an optional headline over equal-weight lines.

The block for what the audio assumes and a cold viewer does not have (“what Hamilton is”, “Philip died at 19”). Heavier than a caption — schedule it with a higher weight so the two never share a corner. Each line wraps to max_width of the safe box and is truncated at _NOTE_MAX_LINES rows; lines are laid out as written, so keep them short.

tituli.on_path(text: str, *, shape: str | Path = 'wave', frame: Frame, style: TextStyle = TextStyle(family=('Georgia', 'Palatino', 'Baskerville', 'Liberation Serif', 'DejaVu Serif', 'Times New Roman'), size=0.035, weight=400, italic=False, condensed=False, color=(17, 17, 17), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0), box: Box | None = None, upright: bool = False, align: Literal['start', 'center', 'end'] = 'center', fit_text: bool = True) Layout[source]

Set text along a shape inside box (default: the safe area, inset).

With fit_text the type is shrunk until the whole string fits the path length (never clipped); the resulting size is in meta["size"].

tituli.parse_color(color: tuple[int, int, int] | tuple[int, int, int, int] | str) tuple[int, int, int, int][source]

Accept "#rgb", "#rrggbb", "#rrggbbaa", a Pillow name or a tuple.

>>> parse_color("#fff")
(255, 255, 255, 255)
>>> parse_color((10, 20, 30))
(10, 20, 30, 255)
tituli.rain(lines: Sequence[str] | str, *, frame: Frame, style: TextStyle = TextStyle(family=('Georgia', 'Palatino', 'Baskerville', 'Liberation Serif', 'DejaVu Serif', 'Times New Roman'), size=0.035, weight=400, italic=False, condensed=False, color=(17, 17, 17), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0), box: Box | None = None, slants: Sequence[float] = (0.186, 0.22, 0.257, 0.298, 0.353), head_offsets: Sequence[float] = (0.0, 7.0, 11.7, 16.4, 19.3), size_ratio: float = 0.86) Layout[source]

Il pleut: each line a streak of upright letters falling across the frame.

Wants a portrait frame. slants (dx per step down) and head_offsets (where each streak starts, in slot units) are shape parameters, not coordinates — the defaults are Apollinaire’s.

tituli.render(layout: Layout, frame: Frame, *, t: float | None = None, engine: Engine | None = None) Image[source]

Render onto the frame: RGBA overlay if it has no background, else RGB.

engine is the rasteriser seam (default Pillow; see tituli.shaping).

tituli.render_overlay(layout: Layout, size: tuple[int, int], *, t: float | None = None, engine: Engine | None = None) Image[source]

The layout on a transparent canvas of size (RGBA).

tituli.reserved_zones(delivery: str | None) tuple[tuple[float, float, float, float], ...][source]

Normalised boxes a delivery target keeps for itself.

>>> reserved_zones("youtube")
((0.0, 0.78, 1.0, 0.22),)
>>> reserved_zones(None)
()
tituli.resolve(overlays: Iterable[TimedOverlay], *, min_readable_s: float = 1.5) list[TimedOverlay][source]

Enforce one overlay per slot at a time: the heavier wins, the lighter yields.

A lighter overlay is truncated to the time before the heavier one starts and dropped only if what remains is under min_readable_s (see _yield_to). Equal weights that collide raise — that is an authoring error, not a rule. Use this on a hand-built list to guarantee two weights never stack.

tituli.resolve_face(family: str | Sequence[str] = ('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), *, size: int, weight: int = 400, italic: bool = False, condensed: bool = False) Face[source]

Resolve a typeface request to a sized Face; never fails.

Falls back to Pillow’s embedded Aileron when nothing in the list is installed, so a render on a fontless CI box still produces a real (if plainer) result.

tituli.resolve_shape(shape: str | Path, box: Box) Path[source]

Turn a name, an SVG d string or a Path into a Path fitted to box.

Names: "line", "circle", "arc", "wave", "diagonal", "s-curve". Anything starting with M/m is parsed as SVG.

tituli.safe_area(width: float, height: float, *, fraction: float = 0.9) Box[source]

The centred box holding fraction of each dimension.

>>> safe_area(1000, 500)
Box(x0=50.0, y0=25.0, x1=950.0, y1=475.0)
tituli.schedule_labels(spans: Sequence[Span], label_for: Callable[[Span], Label | _Unlabelled], *, suppressed_by: Iterable[TimedOverlay] = (), hold_s: float = 4.6, repeat_gap_s: float = 150.0, min_readable_s: float = 1.5, slot: str = 'top-left', weight: int = 1) list[TimedOverlay][source]

One label per span, held hold_s, with the three rules built in.

  • first appearance: a picture is labelled the first time it is shown;

  • repeat gap: and again only if repeat_gap_s has passed since;

  • suppression: a heavier overlay in the same slot owns that moment. The label is truncated to the time before the heavier one starts, and skipped only if what remains is shorter than min_readable_s — and a skipped label is not recorded as shown, so it gets its next chance.

label_for returns a Label or UNLABELLED; None raises.

tituli.title_card(title: str, subtitle: str = '', *, frame: Frame, kicker: str = '', anchor: str = 'center', title_style: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.075, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.01, leading=1.1, case='as-is', align='center', opacity=1.0), subtitle_style: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.02, leading=1.3, case='as-is', align='center', opacity=1.0), kicker_style: TextStyle = TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.022, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.18, leading=1.2, case='upper', align='center', opacity=1.0), ink: tuple[int, int, int, int] | None = None, scrim: bool | None = None) Layout[source]

An opening card: optional kicker, title, optional subtitle.

Fits the title to 80 % of the safe width (shrinking, never clipping), stacks the parts with a gap proportional to the type, places the block at anchor (subject-avoiding when the frame knows the picture), and picks ink by the frame’s contrast. scrim forces a scrim on/off; None decides.

tituli.truncate(text: str, style: TextStyle, frame_height: float, *, max_width: float, max_lines: int) str[source]

Wrap and hard-truncate with an ellipsis at max_lines lines.

For text whose length you do not control (a licence template blob pasted into an artist field). The result always fits.

tituli.wrap(text: str, style: TextStyle, frame_height: float, *, max_width: float) list[str][source]

Greedy word wrap on measured widths. Explicit newlines are honoured.

>>> from tituli.style import CAPTION
>>> lines = wrap("one two three four five six", CAPTION, 1080, max_width=300)
>>> len(lines) >= 2 and all(measure(l, CAPTION, 1080) <= 300 for l in lines)
True