Metadata-Version: 2.5
Name: tituli
Version: 0.0.3
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: 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 → 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

- **`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.
