Metadata-Version: 2.5
Name: tituli
Version: 0.0.5
Summary: Text in video: title cards, credits, captions and calligrams with tasteful defaults
Project-URL: Homepage, https://github.com/thorwhalen/tituli
Project-URL: Repository, https://github.com/thorwhalen/tituli
Project-URL: Documentation, https://thorwhalen.github.io/tituli
Author: Thor Whalen
License-Expression: MIT
License-File: LICENSE
Keywords: calligram,captions,credits,ffmpeg,kinetic-typography,pillow,title-card,typography,video
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Text Processing :: Fonts
Requires-Python: >=3.10
Requires-Dist: pillow>=10.1
Provides-Extra: cli
Requires-Dist: cw<0.2,>=0.1.1; extra == 'cli'
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Provides-Extra: docs
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
Requires-Dist: sphinx>=6.0; extra == 'docs'
Provides-Extra: lacing
Requires-Dist: lacing; extra == 'lacing'
Provides-Extra: saliency
Requires-Dist: burns>=0.0.11; extra == 'saliency'
Provides-Extra: shaping
Requires-Dist: freetype-py; extra == 'shaping'
Requires-Dist: uharfbuzz; extra == 'shaping'
Description-Content-Type: text/markdown

# 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.

```bash
pip install tituli            # Pillow only; ffmpeg on PATH for video output
```

```python
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]`):

```bash
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 `Run`s (a string, a baseline origin, an angle, a resolved face, a colour) over `Plate`s (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 → fit → measure → decide ink → cut the scrim to the measured block → compose.** Your words are set complete or not at all: `caption`, `lower_third` and `note` shrink the type until it fits and raise `TextDoesNotFit` rather than ship a cut label — because `1981 · Centr…` is not a shortened label, it is a wrong one, and only the caller can choose between shorter wording, more lines and no overlay. Text whose length you genuinely don't control (a licence template pasted into an artist field) still truncates, asked for by name: `on_overflow="truncate"`. 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

- **`an` — [thorwhalen/an#155](https://github.com/thorwhalen/an/issues/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`](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.
