Skip to main content

tituli

Text in video, with tasteful defaults: title cards, credits, captions with a tiny attribution, lower thirds, context notes, and calligrams — from one layout model, rendered with Pillow, composited with ffmpeg.

pip install tituli            # Pillow only; ffmpeg on PATH for video output
from tituli import Frame, title_card, caption, render

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

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

Or from the shell (pip install tituli[cli]):

python -m tituli title_card "The Apple" "a concrete poem" --background "#101014" --out title.png
python -m tituli credits credits.json --mode crawl --out credits.mp4
python -m tituli caption still.jpg "Angelica Schuyler Church" "John Trumbull, 1785" --out cap.png
python -m tituli calligram "round and round the apple goes" --shape circle --out calligram.png

One model

Every use case fills a Layout — placed Runs (a string, a baseline origin, an angle, a resolved face, a colour) over Plates (scrims, rules) — on a Frame. A block of prose is one run per line; a calligram is one run per glyph. The humble cases are the degenerate calligram. render(layout, frame) gives an RGBA overlay (frame without a background) or an RGB composite; tituli.video puts either onto footage.

The frame-knowledge ladder

"A video that is text" and "text over a video" are the same problem; what differs is how much the layout engine knows about the picture. Frame carries all four rungs, every one optional, and the engine asks rather than branching:

rung the Frame knows so the engine can
a its size place by anchor in the title-safe area; assume the worst → white on a dark scrim
b a solid colour (Frame.blank(size, color=…)) pick ink by WCAG contrast; no scrim
c the pixels (Frame.from_image(still) or Frame.over(stills)) sample luminance under the candidate box; ink by contrast; scrim only if the patch is mid-toned or busy
d c + regions to keep clear (avoid=) rank the nine anchor positions by how little they cover the subject

Frame.over(stills) samples every frame a camera move will pass through and reports the worst instant, so a scrim over a Ken Burns pan is as light as the whole window allows. delivery="youtube" hard-excludes the bottom 22 % (subtitle track + control bar) from placement — modelled as named reserved zones, not a rule to remember.

Pipeline order (it matters)

shape/wrap → measure → decide ink → cut the scrim to the measured block → compose. Text whose length you don't control (a licence template pasted into an artist field) is wrapped to a width fraction and hard-truncated with an ellipsis inside the package. And never burn text into stills that a camera move will pan and zoom — composite onto the finished motion video (tituli.video.overlay).

What you get

call what it makes
title_card(title, subtitle, kicker=…, frame=…) opening card; title fitted (shrunk, never clipped) to 80 % of the safe width
caption(text, attribution, frame=…) museum label: what's on screen + a small credit line; subject-avoiding; corner-falloff scrim
lower_third(name, role, frame=…) who is speaking
note(lines, headline=…, frame=…) editorial context: a headline over equal-weight lines ("what Hamilton is")
intertitle(text, frame=…) silent-film card, serif italic
Credits.from_dict(…)credits_cards / credits_crawl structured roll: sections, role/name pairs on a gutter, tracked small-cap headings; never truncates (paginates, or raises if you cap the cards)
on_path(text, shape="circle"|"wave"|SVG d|Path) glyphs riding any path, rotated to the tangent or kept upright
rain(lines) Apollinaire's Il pleut: upright letters stepping down fanning streaks (the 1918 measurements as defaults)
in_shape(text, mask) prose poured into a silhouette
schedule_labels(spans, label_for, suppressed_by=cards) one label per shot with first-appearance, repeat-gap and suppression rules inside the loop
tituli.video.still / overlay / crawl / frames_to_video ffmpeg output; only overlay/fade/crop needed — never drawtext/libass

All sizes are fractions of frame height, so a style reads the same at 720p and 4K. The presets (tituli.style) are one type ramp shared by overlays and the end card, so a film is one design.

Scheduling: tituli owns it, or you do — no middle

schedule_labels derives labels from a cut with the three rules built in. A label suppressed by a heavier overlay is truncated to the time before it, kept if what remains is readable, and otherwise skipped without being recorded as shown — so the portrait a cold viewer most needs named still gets labelled the next time it appears. resolve() applies the identical rule to hand-built overlays (one overlay per slot at a time; equal weights colliding raise). label_for returns a Label or the explicit UNLABELLED; None raises — "no caption" is a stated choice, never the cheap default, because an unlabelled still beside a labelled one is an implicit claim.

Seams

# seam v1 default (no new dependency) replacement you can point at
1 subject avoidance — Frame.from_image(avoid=) none (boxes you pass) burns.salient_box, any burns.FacesDetector (pip install tituli[saliency])
2 the rasteriser — render(engine=) PillowEngine (FreeType via Pillow, bitmap rotation) HarfBuzzEngine — shaping + outline transforms (pip install tituli[shaping])
3 the shape — on_path(shape=) named presets an SVG d string or any Path (a traced outline)
4 delivery zones — Frame(delivery=) none DELIVERY_RESERVED["youtube"]; add a target, not a rule
5 the graph — tituli.bodies plain dict from body_for() annot://schema/text-overlay/v1 registered with lacing (pip install tituli[lacing])

Surface for v1: CLI (python -m tituli, cw.dispatch over tituli.tools._dispatch_funcs). MCP/HTTP would project the same list. Agent skill shipped (tituli/data/skills/tituli). NOT seams: the scrim design, the anchor grid, the type ramp, the ffmpeg encode args — written directly, on purpose.

Where this sits in the fleet

  • anthorwhalen/an#155 asked whether per-glyph text belongs in an or a sibling package. tituli is the sibling: it owns typesetting for video (shaping, metrics, wrap, contrast, safe area, reserved zones, path placement, the title/credits/caption conventions) and emits placed glyphs (Run with unit=glyph|word|line and an index). an keeps motion as structure and can consume those placements as its option-2 "svg_sprite per glyph, converted at compile time". tituli's own time envelope is deliberately small (fades, stagger, crawl).
  • muvid keeps its lyric-video vocabulary and ASS burn-in; its calligram archetype's streak solver is tituli.rain, generalised over real glyph metrics and any frame, and text_on_path / concrete_page ceilings (a hardcoded sine; centred rows only; a 0.62-em character estimate) are what on_path / in_shape / real Face.length remove.
  • braidio.video.credits_card is the plain-list case: Credits.from_lines(lines)credits_cards, same never-truncate rule, designed type.
  • burns owns saliency and the Ken Burns move; tituli only consumes salient_box.
  • mixing owns subtitle (SRT) burn-in; tituli does not do subtitles.
  • The Hamilton film's overlays.py (captions + context cards → transparent PNG → one ffmpeg overlay chain) was the seed for caption, note, schedule_labels and video.overlay, with its lessons kept: per-input -loop 1 -t, fade on the still's own clock, -c:a copy, the corner scrim cut to the block, and the suppression check inside the scheduler.

Linked artifacts (lacing)

A caption is an annotation on the image: reference = MediaRef(asset_id=<image hash>), body annot://schema/text-overlay/v1 (tituli.bodies.body_for(layout, frame, text=…) builds it; register() registers it, lazily, only with tituli[lacing]). For the (image, audio-segment) pair no N-ary reference is invented: as artful.PanelBody does, the pair annotation's reference is the interval on the segment and provenance.was_derived_from lists both the image asset_id and the caption annotation id. The body records unlabelled=True when a still was deliberately left without a label.

Style defaults (why they look right)

Title-safe 90 % (SMPTE ST 2046-1); WCAG 4.5:1 aimed for, 3:1 floor; sans working set Helvetica Neue → Inter → Helvetica → Avenir Next → Roboto → … → DejaVu Sans; ≥ 36 px-equivalent at 1080p for anything meant to be read; credits cards hold ≥ 3 s, crawls ≈ 97 px/s at 1080p; reveals 300–500 ms. Sources and the full rationale: misc/docs/style.md. No fonts ship in the package — system discovery with Pillow's embedded Aileron as the fallback, so a bare CI box still renders.

Optional extras

extra adds
shaping uharfbuzz + freetype-py: ligatures, kerning, complex scripts, outline rotation
saliency burns for avoid=salient_box
lacing the body schema
cli cw for python -m tituli

Skills

gh skill install thorwhalen/tituli tituli — or, after pip install, link tituli/data/skills/tituli into your agent's skills directory.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tituli-0.0.3.tar.gz (74.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tituli-0.0.3-py3-none-any.whl (65.6 kB view details)

Uploaded Python 3

File details

Details for the file tituli-0.0.3.tar.gz.

File metadata

  • Download URL: tituli-0.0.3.tar.gz
  • Upload date:
  • Size: 74.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for tituli-0.0.3.tar.gz
Algorithm Hash digest
SHA256 cc44b83e466e5a3e20ad420553e673675868cf6f1641e3f525a0b6cf0e6bd84f
MD5 e99612fd23b7bd9e34450f573aa6b2ee
BLAKE2b-256 8c28ee950036b106d99d50f07e7f7b893031e62c4f3a33cd0e04e5fa3c10faaa

See more details on using hashes here.

File details

Details for the file tituli-0.0.3-py3-none-any.whl.

File metadata

  • Download URL: tituli-0.0.3-py3-none-any.whl
  • Upload date:
  • Size: 65.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for tituli-0.0.3-py3-none-any.whl
Algorithm Hash digest
SHA256 023f289585ceab48044baca3036e4f8891faf1891e256739a503d3b4fe47241d
MD5 93da59f9e710afc5bd5ebc27e4f94815
BLAKE2b-256 34052fb86af2d433a866b1ab5abe4d9ea0d0cb0b3a0e9ca79e5891f3556ffcda

See more details on using hashes here.

Release history Release notifications | RSS feed

0.0.4

2 files

This release

0.0.3 This release

2 files

0.0.2

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page