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[outlines] (fontTools, for
run_outline() — glyph contours as SVG path data), tituli[shaping] (HarfBuzz engine),
tituli[saliency] (burns subject avoidance), tituli[lacing] (the
text-overlay body schema), tituli[cli] (python -m tituli).
Functions
|
An opening card: optional kicker, title, optional subtitle. |
|
A museum label over a picture: what is on screen, plus a tiny credit line. |
|
Who is speaking: a name and a role, left-anchored, scrimmed if needed. |
|
An editorial context card: an optional headline over equal-weight lines. |
|
A silent-film style card: serif italic prose, centred on the frame. |
|
Wrap |
|
Wrap and hard-truncate with an ellipsis at |
|
Choose ink and whether a scrim is needed under |
|
Paginate the roll into cards that each fit the safe area, centred. |
|
One tall layout and its total height in pixels (for |
|
The canonical credits frame: near-black, so ink defaults to white. |
|
Set |
|
Il pleut: each line a streak of upright letters falling across the frame. |
|
Pour prose into a silhouette (light = inside). |
|
Turn a name, an SVG |
|
Lay out prose as lines from the top-left corner |
|
Blocks top to bottom, |
|
Blocks in uniform cells, row-major; one row when |
|
A row of big glyphs, each with a small reading centred under it. |
|
Set |
|
Greedy word wrap on measured widths. |
|
Width in pixels of |
|
Shrink |
|
Render onto the frame: RGBA overlay if it has no background, else RGB. |
|
The layout on a transparent canvas of |
|
Yield one image per video frame, honouring the runs' reveal envelopes. |
|
Resolve an engine by name: |
|
One label per span, held |
|
Enforce one overlay per slot at a time: the heavier wins, the lighter yields. |
|
Sorted family names installed on this machine. |
|
First installed family in the preference list, at the closest style. |
|
Resolve a typeface request to a sized |
|
|
|
The contours of |
|
WCAG contrast ratio between two colours (1..21). |
|
Pick the ink (light or dark) with more contrast against |
|
Accept |
|
The centred box holding |
|
Normalised boxes a delivery target keeps for itself. |
|
Scale |
Classes
|
Size plus whatever else is known about the picture text will sit on. |
|
Everything the renderer needs: runs on top of plates, in frame pixels. |
|
A string drawn from a baseline origin, possibly rotated. |
|
A backdrop drawn under the runs: a scrim, a box, a rule. |
|
A pixel-space rectangle |
|
A polyline with an arc-length parameterisation. |
|
How a run of text looks. |
|
A resolved, sized font ready to measure and draw with Pillow. |
|
The whole roll. |
|
The type ramp for a roll. |
|
One credit line: |
|
A heading over a run of entries. |
|
What goes on a museum label: the thing, and where it came from. |
|
A time range of the cut showing one picture. |
|
A layout (or a thing to lay out) on screen from |
|
A run's contours as SVG path data, in frame pixels. |
Exceptions
|
The text cannot be set completely without going below legibility. |
The run's face has no glyph for one of its characters. |
- class tituli.Box(x0, y0, x1, y1)[source]¶
Bases:
objectA pixel-space rectangle
(x0, y0, x1, y1); edges are floats.- classmethod from_norm(nb, width, height)[source]¶
Scale a normalised
(x, y, w, h)into a frame of that size.- Return type:
- class tituli.Credits(sections=(), title='', closing=(), meta=<factory>)[source]¶
Bases:
objectThe whole roll.
- classmethod from_dict(d)[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.- Return type:
- classmethod from_lines(lines, *, heading='Credits', title='')[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”).- Return type:
- class tituli.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, features=()), 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, features=()), 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, features=()), 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, features=()), 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, features=()), ink=(255, 255, 255), background=(8, 8, 10))[source]¶
Bases:
objectThe type ramp for a roll. One object so a film is one design.
- class tituli.Entry(name, role='')[source]¶
Bases:
objectOne credit line:
role+name, a barename, or prose.
- class tituli.Face(family, style, size, path, index=0, features=())[source]¶
Bases:
objectA resolved, sized font ready to measure and draw with Pillow.
- property applied_features: tuple[str, ...]¶
The requested
featuresthis font has, so the ones measured and outlined with; a requested tag missing here was not applied.>>> resolve_face(EMBEDDED, size=12).applied_features ()
- features: tuple[str, ...] = ()¶
OpenType feature tags the style asked for (tituli#4); which of them the font has is
applied_features.
- length(text)[source]¶
Advance width of
textin pixels.With
applied_features, the advances of the substituted glyphs (tituli.features.featured_length()): the font’s design advances, without hinting or kerning, which is what tabular figures are for.- Return type:
- property pil: FreeTypeFont¶
The Pillow font object (cached per
(path, index, size)).
- class tituli.Frame(width, height, color=None, image=None, avoid=(), reserved=(), samples=())[source]¶
Bases:
objectSize plus whatever else is known about the picture text will sit on.
- classmethod blank(size=(1920, 1080), *, color=None)[source]¶
Rung (a) when
coloris None, rung (b) when it is given.- Return type:
- classmethod from_image(image, *, avoid=None, size=None, delivery=None)[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.- Return type:
- luminance_stats(box)[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.
- classmethod over(stills, *, avoid=None, size=None, delivery=None)[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.)- Return type:
- place(size, *, anchor='auto', region=None)[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, attribution='', key=None)[source]¶
Bases:
objectWhat goes on a museum label: the thing, and where it came from.
- class tituli.Layout(runs=(), plates=(), meta=<factory>)[source]¶
Bases:
objectEverything the renderer needs: runs on top of plates, in frame pixels.
- exception tituli.MissingGlyphError[source]¶
Bases:
ValueErrorThe run’s face has no glyph for one of its characters.
- class tituli.Outline(d, bbox)[source]¶
Bases:
objectA run’s contours as SVG path data, in frame pixels.
bboxis the ink’s bounds (Nonewhen nothing is inked, e.g. a space).
- class tituli.Path(points)[source]¶
Bases:
objectA 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, radius, start_deg, end_deg, *, samples=512)[source]¶
Circular arc; angles in degrees, 0 = right, 90 = down (screen coords).
- Return type:
- classmethod bezier(*controls, samples=512)[source]¶
A Bézier curve of any degree through
controls(De Casteljau).- Return type:
- classmethod circle(center, radius, *, start_deg=-90.0)[source]¶
A full circle starting at the top by default, running clockwise.
- Return type:
- 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, *, keep_aspect=True)[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.
- Return type:
- class tituli.Plate(box, color, kind='box', radius=0.0, solid=(0.5, 0.5))[source]¶
Bases:
objectA backdrop drawn under the runs: a scrim, a box, a rule.
- class tituli.Run(text, x, y, face, color, angle=0.0, opacity=1.0, tracking=0.0, unit='line', index=0, t_in=None, t_full=None, tags=())[source]¶
Bases:
objectA string drawn from a baseline origin, possibly rotated.
- class tituli.Section(heading='', entries=(), kind='auto')[source]¶
Bases:
objectA 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, end, key, data=None)[source]¶
Bases:
objectA 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.)
- exception tituli.TextDoesNotFit(text, tried_size, lines_at_min, max_lines)[source]¶
Bases:
ValueErrorThe text cannot be set completely without going below legibility.
Raised rather than truncating, because the caller is the only one who can decide what to do: shorten the wording (“1981 · Central Park, live” -> “1981 · Central Park”, or “1981”), give it more lines, widen the box, or drop the overlay. Silently ellipsising picks the one option nobody wants — a label that is wrong rather than absent.
- text¶
what could not be set.
- tried_size¶
the smallest size attempted, as a fraction of frame height.
- lines_at_min¶
how many lines it still needed there.
- max_lines¶
how many were allowed.
- class tituli.TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.04, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='center', opacity=1.0, features=())[source]¶
Bases:
objectHow a run of text looks. All fields keyword-only; build variants with
with_.
- class tituli.TimedOverlay(layout, start, end, slot='top-left', weight=1, fade=0.45, payload=None, meta=<factory>, image=None, fade_in=None, fade_out=None)[source]¶
Bases:
objectA 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.imageis the other way in: an already-rendered frame-sized picture (a PIL image or a path) — a title page made elsewhere, a logo plate — timed like any layout.fade_in/fade_outoverridefadefor one end (fade_in=0for a title that must be up on the first frame).
- tituli.along_path(text, path, style, frame, *, start=0.0, align='start', offset=0.0, upright=False, color=None, tags=())[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.- Return type:
- tituli.block(text, style, frame, *, max_width=None, x=0.0, y=0.0, color=None, unit='line', tags=(), break_at=None)[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).break_at(e.g." · ") wraps a list only between its items — seewrap().- Return type:
- tituli.caption(text, attribution='', *, frame, anchor='auto', style=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, features=()), attribution_style=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, features=()), max_lines=3, max_width=0.55, ink=None, scrim=None, accent=True, on_overflow='fit')[source]¶
A museum label over a picture: what is on screen, plus a tiny credit line.
textis wrapped tomax_widthof the safe box and shrunk to fit withinmax_linesrather than cut;attributionis set small and slightly transparent — present enough to credit, small enough not to compete. If the words cannot be set legibly this raisesTextDoesNotFit; passon_overflow="truncate"for text whose length you do not control (seeOVERFLOW_POLICIES). 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.- Return type:
- tituli.contrast_ratio(a, b)[source]¶
WCAG contrast ratio between two colours (1..21).
- Return type:
>>> round(contrast_ratio("#777", "#fff"), 2) 4.48
- tituli.cover_fit(img, size)[source]¶
Scale
imgto fillsizeand crop the overflow, centred.- Return type:
Image
>>> cover_fit(Image.new("RGB", (100, 50)), (50, 50)).size (50, 50)
- tituli.credits_cards(credits, *, frame, style=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, features=()), 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, features=()), 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, features=()), 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, features=()), 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, features=()), ink=(255, 255, 255), background=(8, 8, 10)), max_cards=None)[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, *, frame, style=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, features=()), 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, features=()), 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, features=()), 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, features=()), 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, features=()), ink=(255, 255, 255), background=(8, 8, 10)))[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, style=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, features=()), 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, features=()), 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, features=()), 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, features=()), 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, features=()), ink=(255, 255, 255), background=(8, 8, 10)))[source]¶
The canonical credits frame: near-black, so ink defaults to white.
- Return type:
- tituli.decide_ink(frame, box, *, preferred=None)[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.- Return type:
- tituli.face_digest(face)[source]¶
sha256offace_bytes()— what makes two faces the same face.A family name is not an identity (two machines can install different files under one name, and the embedded face can change with Pillow); the bytes are.
- Return type:
- tituli.find_font(family, *, weight=400, italic=False, condensed=False)[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(text, style, frame_height, *, max_width, max_lines, min_size=0.022)[source]¶
Wrap
textcomplete, shrinking the type until it fits — never cutting it.Returns the wrapped text and the (possibly smaller) style to set it in.
**Why this exists rather than
truncate().** Type here is sized as a fraction of frame height but has to fit the frame’s width. On a portrait frame those pull apart hard: a lower third at 0.042 of height is 81px tall on a 1080x1920 frame and has to fit inside ~1080px of width, so a perfectly ordinary line overflows. Truncating then produced a shipped caption reading1981 · Centr…— which is not a shortened label, it is a wrong one.Shrinking is tried first because it is invisible to the viewer and costs nothing. Only when shrinking would make the text unreadable does this raise
TextDoesNotFit, handing the decision back to whoever wrote the words.- Parameters:
text (
str) – the words, set in full or not at all.style (
TextStyle) – the style to start from; itssizeis the ceiling.frame_height (
float) – pixels, for resolving em sizes.max_width (
float) – pixels available.max_lines (
int) – how many lines the design allows.min_size (
float) – legibility floor, as a fraction of frame height.
- Raises:
TextDoesNotFit – when even
min_sizeneeds more thanmax_lines.- Return type:
Examples
>>> from tituli.style import TextStyle >>> st = TextStyle(size=0.05) >>> body, used = fit("short", st, 1000.0, max_width=900, max_lines=1) >>> body, used.size == st.size ('short', True)
- tituli.fit_size(text, style, frame_height, *, max_width, max_height=None, min_size=0.012, step=0.9, break_at=None)[source]¶
Shrink
style.sizeuntiltextwraps within the given bounds.Shrinks geometrically by
stepand never belowmin_size(a fraction of frame height, likesizeitself).- Return type:
- tituli.frames(layout, frame, *, duration, fps=30.0, engine=None, hold_after=0.0)[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.- Return type:
Iterator[Image]
- tituli.glossed(pairs, *, frame, glyph=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.13, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.0, case='as-is', align='center', opacity=1.0, features=()), gloss=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.045, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.1, case='as-is', align='center', opacity=1.0, features=()), gap=0.012, column_gap=0.08, columns=None, anchor='center')[source]¶
A row of big glyphs, each with a small reading centred under it.
pairsis[(glyph, gloss), ...]—[("カ", "ka"), ("ス", "su")]. Columns share one pitch (the widest column), so readings line up under their glyphs and the row reads as a row.columnswraps a long row into several. Runs are taggedglyph:i/gloss:i(for staggered reveals).- Return type:
>>> lay = glossed([("カ", "ka"), ("ス", "su")], frame=1080) >>> [r.text for r in lay.runs] ['カ', 'ka', 'ス', 'su'] >>> [r.tags for r in lay.runs][:2] [('glyph:0',), ('gloss:0',)]
- tituli.grid(items, *, frame, columns=None, gap=0.03, align='center', anchor='center')[source]¶
Blocks in uniform cells, row-major; one row when
columnsis None.Every cell is as wide as the widest item (so a row of glyphs keeps one pitch whatever each glyph’s advance), each row as tall as its tallest item; items are aligned
alignwithin their cell and to its top.gapis a fraction of frame height, or(column_gap, row_gap).- Return type:
>>> from tituli.layout import block >>> from tituli.style import TextStyle >>> cells = [block(t, TextStyle(size=0.05), 1080) for t in "a b c d".split()] >>> g = grid(cells, frame=1080, columns=2) >>> tops = [round(r.bbox().y0) for r in g.runs] >>> tops[0] == tops[1] < tops[2] == tops[3] # two rows of two True
- tituli.in_shape(text, mask, *, frame, style=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, features=()), box=None, repeat=True)[source]¶
Pour prose into a silhouette (light = inside). See
tituli.layout.fill_shape().- Return type:
- tituli.ink_for(*, luminance, light=(255, 255, 255), dark=(17, 17, 17))[source]¶
Pick the ink (light or dark) with more contrast against
luminance.
- tituli.intertitle(text, *, frame, style=None)[source]¶
A silent-film style card: serif italic prose, centred on the frame.
- Return type:
- tituli.lower_third(name, role='', *, frame, anchor='bottom-left', name_style=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, features=()), role_style=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, features=()), ink=None, scrim=None, on_overflow='fit')[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.The name and role are shrunk to fit rather than cut, and
TextDoesNotFitis raised if they cannot be set legibly. A lower third reading1981 · Centr…once shipped in a finished film; that is not a shortened label but a false one.- Return type:
- tituli.make_engine(name='pillow')[source]¶
Resolve an engine by name:
"pillow"(default) or"harfbuzz".- Return type:
Engine
- tituli.measure(text, style, frame_height)[source]¶
Width in pixels of
textset instyleon a frame of that height.- Return type:
- tituli.note(lines, *, frame, headline='', anchor='top-left', headline_style=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, features=()), line_style=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, features=()), max_width=0.62, ink=None, scrim=None, accent=True, on_overflow='fit')[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, shrinking to fit within_NOTE_MAX_LINESrows rather than being cut; every line shares the smallest size any of them needed, so the block reads as one block. RaisesTextDoesNotFitif a line cannot be set legibly.- Return type:
- tituli.on_path(text, *, shape='wave', frame, style=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, features=()), box=None, upright=False, align='center', fit_text=True)[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"].- Return type:
- tituli.parse_color(color)[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, *, frame, style=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, features=()), box=None, slants=(0.186, 0.22, 0.257, 0.298, 0.353), head_offsets=(0.0, 7.0, 11.7, 16.4, 19.3), size_ratio=0.86)[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.- Return type:
- tituli.render(layout, frame, *, t=None, engine=None)[source]¶
Render onto the frame: RGBA overlay if it has no background, else RGB.
engineis the rasteriser seam (default Pillow; seetituli.shaping).- Return type:
Image
- tituli.render_overlay(layout, size, *, t=None, engine=None)[source]¶
The layout on a transparent canvas of
size(RGBA).- Return type:
Image
- tituli.reserved_zones(delivery)[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, *, min_readable_s=1.5)[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.- Return type:
- tituli.resolve_face(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), *, size, weight=400, italic=False, condensed=False, features=())[source]¶
Resolve a typeface request to a sized
Face; never fails.featuresare OpenType feature tags to apply (("tnum",)), carried by the face; seeFace.applied_featuresfor which the font has.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.
EMBEDDEDin the list stops the search there (see its comment):- Return type:
>>> resolve_face(EMBEDDED, size=20).path is None True >>> resolve_face(["definitely-not-installed-xyz", EMBEDDED], size=20).family 'Aileron'
- tituli.resolve_shape(shape, box)[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.- Return type:
- tituli.run_outline(run, *, precision=2)[source]¶
The contours of
runas one SVG path, placed where the run is drawn.Glyph
istarts atface.length(text[:i]) + i * trackingalong the baseline — the same advances the layout measured with, kerning included, so the outline and the layout cannot disagree about where a glyph is.- Return type:
- tituli.safe_area(width, height, *, fraction=0.9)[source]¶
The centred box holding
fractionof each dimension.- Return type:
>>> safe_area(1000, 500) Box(x0=50.0, y0=25.0, x1=950.0, y1=475.0)
- tituli.schedule_labels(spans, label_for, *, suppressed_by=(), hold_s=4.6, repeat_gap_s=150.0, min_readable_s=1.5, slot='top-left', weight=1)[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.- Return type:
- tituli.stack(items, *, frame, gap=0.02, align='center', anchor='center')[source]¶
Blocks top to bottom,
gapapart, aligned within the widest.gapis a fraction of frame height: one number for every seam, or one per seam (len(items) - 1values) when the page needs a rhythm. A block with no runs is kept as a zero-height item, so per-seam gaps still line up. Box plates travel with their block; a frame-anchored scrim raises (see_measurable). A page larger than the title-safe area raises rather than run off the frame. Parts’ loss reports (overflow/unplaced) are summed into the result’s meta, and each part’s meta is kept undermeta["parts"].- Return type:
>>> from tituli.layout import block >>> from tituli.style import TextStyle >>> a = block("wide line of text", TextStyle(size=0.05), 1080) >>> b = block("x", TextStyle(size=0.05), 1080) >>> s = stack([a, b], frame=1080, align="left") >>> s.runs[0].bbox().x0 == s.runs[1].bbox().x0 True
- tituli.title_card(title, subtitle='', *, frame, kicker='', anchor='center', title_style=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, features=()), subtitle_style=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, features=()), kicker_style=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, features=()), ink=None, scrim=None)[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.- Return type:
- tituli.truncate(text, style, frame_height, *, max_width, max_lines)[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.
- Return type:
- tituli.wrap(text, style, frame_height, *, max_width, break_at=None)[source]¶
Greedy word wrap on measured widths. Explicit newlines are honoured.
break_atmakes a list wrap only between its items: with" · ","バス bus · スープ soup"never ends a line onバス. An item wider than the whole line falls back to word wrapping inside that item (it is never cut). A no-break space (U+00A0) binds two words in any mode.>>> 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
Modules
Layout combinators: several blocks composed into one |
|
A lacing body schema for a rendered caption ( |
|
Calligrams and concrete poems: text whose shape is part of the meaning. |
|
Colours, WCAG contrast, and the ink-for-this-background decision. |
|
The composed pieces: title cards, captions with attribution, lower thirds. |
|
Credits: structured data in, designed cards or a crawl out. |
|
OpenType features a style asks for, applied without a shaper where they can be (tituli#4). |
|
Font discovery with no bundled fonts. |
|
The frame: what the layout engine is allowed to know about the picture. |
|
Boxes, anchors, safe areas and parametric paths — the coordinate vocabulary. |
|
The layout model: placed runs of text, and the engines that place them. |
|
Glyph outlines as SVG path data — the vector form of a placed |
|
When overlays appear: timed overlays, and a scheduler that owns the rules. |
|
Optional engine: HarfBuzz shaping + FreeType rendering ( |
|
Text styles and the tasteful presets. |
|
The SSOT of dispatchable operations: flat, JSON-able in, JSON-able out. |
|
Video output through ffmpeg: stills to clips, overlays onto footage, crawls. |