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
dxhorizontally anddy(defaultdx) 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_cardtakes).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; preferfrom_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 barename, 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.
- property line_height: int
Ascent + descent.
- property pil: FreeTypeFont
The Pillow font object (cached per
(path, index, 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
coloris 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
avoidis given.imagemay 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 feedluminance_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.avoidis normalised boxes, or a callable taking the PIL image and returning one box or several (burns.salient_boxfits directly). It runs on every sampled frame.sizecover-fits the picture(s) to the frame first.deliverynames 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 underbox; 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_statsreports the worst instant — so a scrim over a Ken Burns move is as light as the whole window allows. (from_imageaccepts a sequence too; this name says what it is for.)
- 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.anchormay 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'
- 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.
- property duration_hint: float
Latest
t_fullamong the runs (0 when untimed).
- 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)andangle(s)(degrees, clockwise-positive on screen because y points 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)fortin[0, 1].
- 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.
- class tituli.Section(heading: str = '', entries: tuple[Entry, ...] = (), kind: str = 'auto')[source]
A heading over a run of entries.
kindpicks 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.
keyidentifies the picture.(Named
Spanrather thanPanelso it never collides withbraidio.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_.
- 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
starttoend.slotnames the region it occupies;weightorders overlays that contend for a slot — the heavier suppresses the lighter, never stacks.payloadis whatever the caller wants to carry to rendering (aLabel, a dict, …);layoutis 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
textglyph by glyph alongpath(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).offsetshifts the baseline perpendicular to the path (negative = above, in screen terms).alignpositions the whole string on the path fromstart.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).textis a string (wrapped tomax_widthwhen given) or pre-broken lines. Alignment followsstyle.alignwithinmax_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.
textis wrapped tomax_widthof the safe box and hard-truncated atmax_lines;attributionis set small and slightly transparent — present enough to credit, small enough not to compete. Placement avoids the frame’savoidboxes 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
imgto fillsizeand 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_cardswould 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.
preferredis honoured when it already has enough contrast.
- 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.
familymay 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.sizeuntiltextwraps within the given bounds.Shrinks geometrically by
stepand never belowmin_size(a fraction of frame height, likesizeitself).
- 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.
durationis the whole clip; the last reveal completes atlayout.duration_hintand 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
textset instyleon 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
weightso the two never share a corner. Each line wraps tomax_widthof the safe box and is truncated at_NOTE_MAX_LINESrows; 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
textalong a shape insidebox(default: the safe area, inset).With
fit_textthe type is shrunk until the whole string fits the path length (never clipped); the resulting size is inmeta["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) andhead_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.
engineis the rasteriser seam (default Pillow; seetituli.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
dstring or a Path into a Path fitted tobox.Names:
"line","circle","arc","wave","diagonal","s-curve". Anything starting withM/mis parsed as SVG.
- tituli.safe_area(width: float, height: float, *, fraction: float = 0.9) Box[source]
The centred box holding
fractionof 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_shas 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_forreturns aLabelorUNLABELLED;Noneraises.
- 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.scrimforces 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_lineslines.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