tituli 0.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tituli-0.0.2/.claude/CLAUDE.md +32 -0
- tituli-0.0.2/.claude/skills/tituli/SKILL.md +104 -0
- tituli-0.0.2/.gitattributes +1 -0
- tituli-0.0.2/.github/workflows/ci.yml +43 -0
- tituli-0.0.2/.gitignore +120 -0
- tituli-0.0.2/LICENSE +21 -0
- tituli-0.0.2/PKG-INFO +162 -0
- tituli-0.0.2/README.md +118 -0
- tituli-0.0.2/misc/CHANGELOG.md +5 -0
- tituli-0.0.2/misc/docs/style.md +73 -0
- tituli-0.0.2/pyproject.toml +214 -0
- tituli-0.0.2/tests/test_compose.py +299 -0
- tituli-0.0.2/tests/test_core.py +256 -0
- tituli-0.0.2/tests/test_surfaces.py +135 -0
- tituli-0.0.2/tituli/__init__.py +127 -0
- tituli-0.0.2/tituli/__main__.py +8 -0
- tituli-0.0.2/tituli/bodies.py +119 -0
- tituli-0.0.2/tituli/calligram.py +164 -0
- tituli-0.0.2/tituli/color.py +158 -0
- tituli-0.0.2/tituli/compose.py +485 -0
- tituli-0.0.2/tituli/credits.py +323 -0
- tituli-0.0.2/tituli/fonts.py +309 -0
- tituli-0.0.2/tituli/frame.py +369 -0
- tituli-0.0.2/tituli/geometry.py +475 -0
- tituli-0.0.2/tituli/layout.py +623 -0
- tituli-0.0.2/tituli/render.py +263 -0
- tituli-0.0.2/tituli/schedule.py +227 -0
- tituli-0.0.2/tituli/shaping.py +134 -0
- tituli-0.0.2/tituli/style.py +129 -0
- tituli-0.0.2/tituli/tools.py +379 -0
- tituli-0.0.2/tituli/video.py +478 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# tituli — agent & contributor guide
|
|
2
|
+
|
|
3
|
+
Text in video: title cards, credits, captions + attribution, lower thirds, notes, calligrams. One `Layout` of placed `Run`s over `Plate`s on a `Frame`; Pillow rasterises, ffmpeg composites. See README for the API and the seam table.
|
|
4
|
+
|
|
5
|
+
## Seams (current default → replacement)
|
|
6
|
+
|
|
7
|
+
1. `Frame.from_image(avoid=)` — none → `burns.salient_box` / any `FacesDetector` (`[saliency]`)
|
|
8
|
+
2. `render(engine=)` — `PillowEngine` → `tituli.shaping.HarfBuzzEngine` (`[shaping]`)
|
|
9
|
+
3. `on_path(shape=)` — named presets → SVG `d` / `Path`
|
|
10
|
+
4. `Frame(delivery=)` — none → `DELIVERY_RESERVED["youtube"]` (add a target, never a caller-side rule)
|
|
11
|
+
5. `tituli.bodies` — plain dict → `annot://schema/text-overlay/v1` in lacing (`[lacing]`)
|
|
12
|
+
|
|
13
|
+
Surfaces built: CLI (`python -m tituli` over `tituli.tools._dispatch_funcs`), shipped skill. MCP/HTTP would project the same list — never author a second one.
|
|
14
|
+
|
|
15
|
+
## Invariants (tests pin these — change deliberately)
|
|
16
|
+
|
|
17
|
+
- `import tituli` pulls no surface library and no optional dependency (`tests/test_surfaces.py`).
|
|
18
|
+
- **Nothing is silently left off the film.** `label_for` returning `None` raises (`UNLABELLED` is the explicit choice); credits paginate and raise rather than truncate; `video.overlay` renders every overlay or raises *naming* the ones it cannot; equal-weight collisions raise.
|
|
19
|
+
- One suppression rule for both scheduling paths: `_yield_to` — a heavier overlay takes the time it needs; the lighter keeps what is left if still readable. `schedule_labels` and `resolve` must agree (invariant test).
|
|
20
|
+
- Pipeline order: wrap → measure → ink → scrim cut to the block → compose. A scrim sized before measuring is the bug this order exists to prevent.
|
|
21
|
+
- Sizes are fractions of frame height; time is seconds; boxes crossing a boundary are normalised `(x, y, w, h)`.
|
|
22
|
+
- Never burn text into stills a camera move will pan; composite onto the finished motion video.
|
|
23
|
+
- A **tracked** style (`tracking != 0`) lays out one run per glyph (Pillow cannot track) — tests that count runs must use an untracked style.
|
|
24
|
+
- No fonts ship in the package. Discovery + Pillow's embedded Aileron fallback.
|
|
25
|
+
|
|
26
|
+
## Reviewing
|
|
27
|
+
|
|
28
|
+
A description of a design and the code that implements it are different artifacts; reviewing the first says nothing about the second. This package's first review found three real defects that its (accurate) API report could not show — one of them the silent-drop the scheduler was built to prevent, reintroduced one layer down in `video.overlay`. Read the code path a consumer will actually take, end to end, before calling it done. (Same lesson as braidio's `nw#27` note: check the artifact, not the log.)
|
|
29
|
+
|
|
30
|
+
## Neighbours
|
|
31
|
+
|
|
32
|
+
`burns` (saliency, Ken Burns), `mixing` (SRT burn-in — not tituli), `braidio.video.credits_card` (plain-list credits: `Credits.from_lines`), `muvid` (lyric vocabulary; its `calligram` solver is `tituli.rain`), `an` (motion as structure; an#155 is the boundary), `illustration` (sourcing the stills captions describe).
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tituli
|
|
3
|
+
description: Put tasteful text on video or stills with the `tituli` Python package — title cards, end credits from structured data, captions with a tiny source attribution that stay off the subject, lower thirds, editorial context notes, and calligrams / concrete poems (text along a circle, wave, SVG path, or falling like Apollinaire's "Il pleut"). Use when asked to "add credits", "add a title card", "caption this image", "label these stills", "put a name and role on screen", "add context cards", "overlay text on this video", "make a calligram", "text on a path", or when a film needs on-screen text that looks designed rather than debug-dumped. Owns placement (title-safe, subject-avoiding, YouTube subtitle band reserved), ink/scrim by contrast, wrapping and truncation, scheduling of captions against heavier cards, and ffmpeg compositing. Not for SRT subtitles (use `mixing`).
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
audience: users
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# tituli — text in video
|
|
10
|
+
|
|
11
|
+
`pip install tituli` (Pillow only). Video output needs `ffmpeg` on PATH (only `overlay`/`fade`/`crop`; never `drawtext`). Extras: `[saliency]` (burns, subject avoidance), `[shaping]` (HarfBuzz), `[lacing]`, `[cli]`.
|
|
12
|
+
|
|
13
|
+
## The one model
|
|
14
|
+
|
|
15
|
+
Every call returns a `Layout`; `render(layout, frame)` gives an image; `tituli.video.*` puts images on footage. A `Frame` says how much is known about the picture:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from tituli import Frame
|
|
19
|
+
|
|
20
|
+
Frame.blank((1920, 1080)) # knows only its size -> white on a dark scrim
|
|
21
|
+
Frame.blank((1920, 1080), color="#101014") # solid colour -> ink by contrast, no scrim
|
|
22
|
+
Frame.from_image(
|
|
23
|
+
"still.jpg", delivery="youtube"
|
|
24
|
+
) # samples pixels; keeps the subtitle band clear
|
|
25
|
+
Frame.from_image("still.jpg", avoid=burns.salient_box) # ...and keeps off the subject
|
|
26
|
+
Frame.over(
|
|
27
|
+
["f01.jpg", "f02.jpg", "f03.jpg"], avoid=burns.salient_box
|
|
28
|
+
) # the frames under a camera move; worst instant wins
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Recipes
|
|
32
|
+
|
|
33
|
+
**Title card** (PNG, or MP4 with a duration):
|
|
34
|
+
```python
|
|
35
|
+
from tituli import Frame, title_card, render
|
|
36
|
+
|
|
37
|
+
f = Frame.blank((1920, 1080), color="#101014")
|
|
38
|
+
render(title_card("The Apple", "a concrete poem", kicker="Episode 3", frame=f), f).save(
|
|
39
|
+
"title.png"
|
|
40
|
+
)
|
|
41
|
+
# video: tituli.video.still(image, "title.mp4", duration=4)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Caption + tiny attribution on a still** — text is wrapped and truncated for you; attribution is small on purpose:
|
|
45
|
+
```python
|
|
46
|
+
from tituli import Frame, caption, render
|
|
47
|
+
|
|
48
|
+
f = Frame.from_image(
|
|
49
|
+
"still.jpg", delivery="youtube"
|
|
50
|
+
) # add avoid=burns.salient_box if burns is installed
|
|
51
|
+
lay = caption(
|
|
52
|
+
"Angelica Schuyler Church", "John Trumbull, 1785 · public domain", frame=f
|
|
53
|
+
)
|
|
54
|
+
render(lay, f).save(
|
|
55
|
+
"captioned.png"
|
|
56
|
+
) # or render_overlay(lay, f.size) for a transparent PNG
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Lower third / context note**: `lower_third("Eliza Hamilton", "née Schuyler", frame=f)`; `note(["What Hamilton is", "Who Chernow is"], headline="Before we go on", frame=f)`.
|
|
60
|
+
|
|
61
|
+
**Credits** — structured, never a wall of filenames, never truncated:
|
|
62
|
+
```python
|
|
63
|
+
from tituli import Credits, credits_cards, credits_crawl, credits_frame, render, Frame
|
|
64
|
+
|
|
65
|
+
cr = Credits.from_dict(
|
|
66
|
+
{
|
|
67
|
+
"title": "The Apple",
|
|
68
|
+
"sections": [
|
|
69
|
+
{"heading": "Voices", "entries": [["Narrator", "T. Whalen"]]},
|
|
70
|
+
{"heading": "Images", "entries": ["Still Life — Cézanne — public domain"]},
|
|
71
|
+
],
|
|
72
|
+
"closing": ["Made with tituli"],
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
f = credits_frame((1920, 1080))
|
|
76
|
+
for i, card in enumerate(credits_cards(cr, frame=f)):
|
|
77
|
+
render(card, f).save(f"credits_{i}.png")
|
|
78
|
+
lay, total = credits_crawl(
|
|
79
|
+
cr, frame=f
|
|
80
|
+
) # then tituli.video.crawl(render(lay, Frame.blank((1920, total), color=f.color)), "credits.mp4", size=(1920,1080), speed_px_s=97)
|
|
81
|
+
```
|
|
82
|
+
`Credits.from_lines(lines)` takes a plain list (braidio's `credits_card` shape).
|
|
83
|
+
|
|
84
|
+
**Captions on a finished film** (composite onto the motion video, never into the stills):
|
|
85
|
+
```python
|
|
86
|
+
from tituli import Span, Label, UNLABELLED, schedule_labels, TimedOverlay, resolve, note
|
|
87
|
+
from tituli.video import overlay
|
|
88
|
+
f = Frame.blank((1920, 1080)).with_delivery("youtube")
|
|
89
|
+
cards = [TimedOverlay(note([...], headline="...", frame=f), 12.0, 18.0, slot="top-left", weight=2)]
|
|
90
|
+
spans = [Span(start, end, key=still_id) for ...] # your cut
|
|
91
|
+
labels = schedule_labels(spans, lambda s: Label(text, attribution) if known else UNLABELLED, suppressed_by=cards)
|
|
92
|
+
overlay("film.mp4", resolve([*cards, *labels]), "film_captioned.mp4") # renders Label payloads itself; raises naming anything it can't
|
|
93
|
+
```
|
|
94
|
+
Rules built in: label on first appearance, again only after 150 s, truncated (not dropped) against a heavier card, and a suppressed label is not counted as shown. Returning `None` from `label_for` raises — say `UNLABELLED` when a still is deliberately unlabelled.
|
|
95
|
+
|
|
96
|
+
**Calligram**: `on_path(text, shape="circle"|"wave"|"arc"|"M 0 0 C ..."|Path, frame=f, upright=False)`; `rain(lines, frame=Frame.blank((1080, 1920), color="#f4f1e8"))` (portrait); `in_shape(text, mask_image, frame=f)`. A glyph-by-glyph reveal: `lay.staggered(step=0.05, ramp=0.2)` → `tituli.video.frames_to_video(frames(lay, f, duration=6), "out.mp4", size=f.size)`.
|
|
97
|
+
|
|
98
|
+
## CLI
|
|
99
|
+
|
|
100
|
+
`python -m tituli title_card "Title" "sub" --background "#101014" --out t.png` · `python -m tituli caption still.jpg "text" "source" --out c.png` · `python -m tituli credits spec.json --mode crawl --out credits.mp4` · `python -m tituli calligram "text" --shape circle --out c.png` · `python -m tituli overlay_video film.mp4 overlays.json --out out.mp4` · `python -m tituli fonts Helvetica`.
|
|
101
|
+
|
|
102
|
+
## Taste rules the defaults already follow
|
|
103
|
+
|
|
104
|
+
Title-safe 90 %; WCAG 4.5:1 target; sans working set (Helvetica Neue → Inter → … → DejaVu Sans) with Pillow's Aileron as the last fallback; ≥ 36 px-equivalent at 1080p for anything meant to be read; attribution ~22 px and 85 % opacity; credits hold ≥ 3 s / crawl ≈ 97 px/s; reveals 0.4 s; corner-falloff scrim cut to the text block, only when contrast or busyness demands it. Sizes are fractions of frame height, so the same call is right at 720p and 4K. Don't override these unless asked.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*.ipynb linguist-documentation
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# wads CI — calls the reusable workflow hosted in i2mint/wads.
|
|
2
|
+
#
|
|
3
|
+
# All configuration comes from this repo's pyproject.toml [tool.wads.ci.*].
|
|
4
|
+
# To customize the workflow itself (rare), replace this file with the
|
|
5
|
+
# full inline template `wads/data/github_ci_uv.yml` from i2mint/wads.
|
|
6
|
+
#
|
|
7
|
+
# Pinning: `@master` floats with wads. If you need version stability for
|
|
8
|
+
# a release-sensitive repo, change `@master` to a wads tag (e.g. `@0.2.15`;
|
|
9
|
+
# tags have no `v` prefix). A stub whose `secrets:` block passes the JSON
|
|
10
|
+
# transport (the default below) needs a tag from a release after 0.2.14 —
|
|
11
|
+
# older tags don't declare that secret and GitHub then rejects the
|
|
12
|
+
# workflow at parse time.
|
|
13
|
+
# CI failure does not block a published release — it blocks the publish
|
|
14
|
+
# step itself — so floating master is generally safe.
|
|
15
|
+
#
|
|
16
|
+
# Permissions: GitHub validates that the caller grants AT LEAST the
|
|
17
|
+
# permissions any job in the called workflow requests — at workflow-parse
|
|
18
|
+
# time, not at run-time, even if the job would be skipped via `if:`.
|
|
19
|
+
# The reusable workflow needs:
|
|
20
|
+
# contents: write for the publish job's version-bump push-back
|
|
21
|
+
# and for the github-pages job's gh-pages branch push
|
|
22
|
+
# pages: write for the github-pages job's REST API Pages config
|
|
23
|
+
# Both default to `write` on org-account GITHUB_TOKEN and need to be
|
|
24
|
+
# granted explicitly on personal-account callers (where the default is
|
|
25
|
+
# read-only). No `id-token: write` needed — the publish-github-pages
|
|
26
|
+
# action uses peaceiris/actions-gh-pages (branch-based) + REST API,
|
|
27
|
+
# not the OIDC `actions/deploy-pages` flow.
|
|
28
|
+
name: Continuous Integration
|
|
29
|
+
on: [push, pull_request]
|
|
30
|
+
jobs:
|
|
31
|
+
ci:
|
|
32
|
+
uses: i2mint/wads/.github/workflows/uv-ci.yml@master
|
|
33
|
+
permissions:
|
|
34
|
+
contents: write
|
|
35
|
+
pages: write
|
|
36
|
+
# Transport (NAMED, legacy): explicitly passes only the secrets
|
|
37
|
+
# listed below (PYPI_PASSWORD + those declared in
|
|
38
|
+
# [tool.wads.ci.env]). Every name must be in the frozen wads
|
|
39
|
+
# superset (wads/ci_secrets.py) or GitHub rejects the workflow
|
|
40
|
+
# at parse time. The default JSON transport has no such limit;
|
|
41
|
+
# regenerate with `wads-migrate ci-to-stub` to switch.
|
|
42
|
+
secrets:
|
|
43
|
+
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
tituli-0.0.2/.gitignore
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
.claude/handoffs/
|
|
2
|
+
.claude/scratch/
|
|
3
|
+
|
|
4
|
+
# Byte-compiled / optimized / DLL files
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
.DS_Store
|
|
11
|
+
# C extensions
|
|
12
|
+
*.so
|
|
13
|
+
|
|
14
|
+
# TLS certificates
|
|
15
|
+
## Ignore all PEM files anywhere
|
|
16
|
+
*.pem
|
|
17
|
+
## Also ignore any certs directory
|
|
18
|
+
certs/
|
|
19
|
+
|
|
20
|
+
# Distribution / packaging
|
|
21
|
+
.Python
|
|
22
|
+
build/
|
|
23
|
+
develop-eggs/
|
|
24
|
+
dist/
|
|
25
|
+
downloads/
|
|
26
|
+
eggs/
|
|
27
|
+
.eggs/
|
|
28
|
+
lib/
|
|
29
|
+
lib64/
|
|
30
|
+
parts/
|
|
31
|
+
sdist/
|
|
32
|
+
var/
|
|
33
|
+
wheels/
|
|
34
|
+
*.egg-info/
|
|
35
|
+
.installed.cfg
|
|
36
|
+
*.egg
|
|
37
|
+
MANIFEST
|
|
38
|
+
_build
|
|
39
|
+
|
|
40
|
+
# PyInstaller
|
|
41
|
+
# Usually these files are written by a python script from a template
|
|
42
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
43
|
+
*.manifest
|
|
44
|
+
*.spec
|
|
45
|
+
|
|
46
|
+
# Installer logs
|
|
47
|
+
pip-log.txt
|
|
48
|
+
pip-delete-this-directory.txt
|
|
49
|
+
|
|
50
|
+
# Unit test / coverage reports
|
|
51
|
+
htmlcov/
|
|
52
|
+
.tox/
|
|
53
|
+
.coverage
|
|
54
|
+
.coverage.*
|
|
55
|
+
.cache
|
|
56
|
+
nosetests.xml
|
|
57
|
+
coverage.xml
|
|
58
|
+
*.cover
|
|
59
|
+
.hypothesis/
|
|
60
|
+
.pytest_cache/
|
|
61
|
+
|
|
62
|
+
# Translations
|
|
63
|
+
*.mo
|
|
64
|
+
*.pot
|
|
65
|
+
|
|
66
|
+
# Django stuff:
|
|
67
|
+
*.log
|
|
68
|
+
local_settings.py
|
|
69
|
+
db.sqlite3
|
|
70
|
+
|
|
71
|
+
# Flask stuff:
|
|
72
|
+
instance/
|
|
73
|
+
.webassets-cache
|
|
74
|
+
|
|
75
|
+
# Scrapy stuff:
|
|
76
|
+
.scrapy
|
|
77
|
+
|
|
78
|
+
# Sphinx documentation
|
|
79
|
+
docs/_build/
|
|
80
|
+
docs/*
|
|
81
|
+
|
|
82
|
+
# PyBuilder
|
|
83
|
+
target/
|
|
84
|
+
|
|
85
|
+
# Jupyter Notebook
|
|
86
|
+
.ipynb_checkpoints
|
|
87
|
+
|
|
88
|
+
# pyenv
|
|
89
|
+
.python-version
|
|
90
|
+
|
|
91
|
+
# celery beat schedule file
|
|
92
|
+
celerybeat-schedule
|
|
93
|
+
|
|
94
|
+
# SageMath parsed files
|
|
95
|
+
*.sage.py
|
|
96
|
+
|
|
97
|
+
# Environments
|
|
98
|
+
.env
|
|
99
|
+
.venv
|
|
100
|
+
env/
|
|
101
|
+
venv/
|
|
102
|
+
ENV/
|
|
103
|
+
env.bak/
|
|
104
|
+
venv.bak/
|
|
105
|
+
|
|
106
|
+
# Spyder project settings
|
|
107
|
+
.spyderproject
|
|
108
|
+
.spyproject
|
|
109
|
+
|
|
110
|
+
# Rope project settings
|
|
111
|
+
.ropeproject
|
|
112
|
+
|
|
113
|
+
# mkdocs documentation
|
|
114
|
+
/site
|
|
115
|
+
|
|
116
|
+
# mypy
|
|
117
|
+
.mypy_cache/
|
|
118
|
+
|
|
119
|
+
# PyCharm
|
|
120
|
+
.idea
|
tituli-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thor Whalen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tituli-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tituli
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Text in video: title cards, credits, captions and calligrams with tasteful defaults
|
|
5
|
+
Project-URL: Homepage, https://github.com/thorwhalen/tituli
|
|
6
|
+
Project-URL: Repository, https://github.com/thorwhalen/tituli
|
|
7
|
+
Project-URL: Documentation, https://thorwhalen.github.io/tituli
|
|
8
|
+
Author: Thor Whalen
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: calligram,captions,credits,ffmpeg,kinetic-typography,pillow,title-card,typography,video
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
23
|
+
Classifier: Topic :: Multimedia :: Video
|
|
24
|
+
Classifier: Topic :: Text Processing :: Fonts
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: pillow>=10.1
|
|
27
|
+
Provides-Extra: cli
|
|
28
|
+
Requires-Dist: cw<0.2,>=0.1.1; extra == 'cli'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
33
|
+
Provides-Extra: docs
|
|
34
|
+
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
35
|
+
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
36
|
+
Provides-Extra: lacing
|
|
37
|
+
Requires-Dist: lacing; extra == 'lacing'
|
|
38
|
+
Provides-Extra: saliency
|
|
39
|
+
Requires-Dist: burns>=0.0.11; extra == 'saliency'
|
|
40
|
+
Provides-Extra: shaping
|
|
41
|
+
Requires-Dist: freetype-py; extra == 'shaping'
|
|
42
|
+
Requires-Dist: uharfbuzz; extra == 'shaping'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# tituli
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install tituli # Pillow only; ffmpeg on PATH for video output
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from tituli import Frame, title_card, caption, render
|
|
55
|
+
|
|
56
|
+
frame = Frame.blank((1920, 1080), color="#101014")
|
|
57
|
+
render(
|
|
58
|
+
title_card("Il pleut", "Apollinaire, 1918", kicker="Episode 3", frame=frame), frame
|
|
59
|
+
).save("title.png")
|
|
60
|
+
|
|
61
|
+
frame = Frame.from_image(
|
|
62
|
+
"still.jpg", delivery="youtube"
|
|
63
|
+
) # add avoid=burns.salient_box to keep off the subject
|
|
64
|
+
render(
|
|
65
|
+
caption("Eliza Hamilton", "Ralph Earl, 1787 · public domain", frame=frame), frame
|
|
66
|
+
).save("captioned.png")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or from the shell (`pip install tituli[cli]`):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m tituli title_card "The Apple" "a concrete poem" --background "#101014" --out title.png
|
|
73
|
+
python -m tituli credits credits.json --mode crawl --out credits.mp4
|
|
74
|
+
python -m tituli caption still.jpg "Angelica Schuyler Church" "John Trumbull, 1785" --out cap.png
|
|
75
|
+
python -m tituli calligram "round and round the apple goes" --shape circle --out calligram.png
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## One model
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
### The frame-knowledge ladder
|
|
83
|
+
|
|
84
|
+
"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:
|
|
85
|
+
|
|
86
|
+
| rung | the Frame knows | so the engine can |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| a | its size | place by anchor in the title-safe area; assume the worst → white on a dark scrim |
|
|
89
|
+
| b | a solid colour (`Frame.blank(size, color=…)`) | pick ink by WCAG contrast; no scrim |
|
|
90
|
+
| 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 |
|
|
91
|
+
| d | c + regions to keep clear (`avoid=`) | rank the nine anchor positions by how little they cover the subject |
|
|
92
|
+
|
|
93
|
+
`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.
|
|
94
|
+
|
|
95
|
+
### Pipeline order (it matters)
|
|
96
|
+
|
|
97
|
+
**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`).
|
|
98
|
+
|
|
99
|
+
## What you get
|
|
100
|
+
|
|
101
|
+
| call | what it makes |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `title_card(title, subtitle, kicker=…, frame=…)` | opening card; title fitted (shrunk, never clipped) to 80 % of the safe width |
|
|
104
|
+
| `caption(text, attribution, frame=…)` | museum label: what's on screen + a small credit line; subject-avoiding; corner-falloff scrim |
|
|
105
|
+
| `lower_third(name, role, frame=…)` | who is speaking |
|
|
106
|
+
| `note(lines, headline=…, frame=…)` | editorial context: a headline over equal-weight lines ("what *Hamilton* is") |
|
|
107
|
+
| `intertitle(text, frame=…)` | silent-film card, serif italic |
|
|
108
|
+
| `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) |
|
|
109
|
+
| `on_path(text, shape="circle"\|"wave"\|SVG d\|Path)` | glyphs riding any path, rotated to the tangent or kept upright |
|
|
110
|
+
| `rain(lines)` | Apollinaire's *Il pleut*: upright letters stepping down fanning streaks (the 1918 measurements as defaults) |
|
|
111
|
+
| `in_shape(text, mask)` | prose poured into a silhouette |
|
|
112
|
+
| `schedule_labels(spans, label_for, suppressed_by=cards)` | one label per shot with first-appearance, repeat-gap and suppression rules **inside** the loop |
|
|
113
|
+
| `tituli.video.still / overlay / crawl / frames_to_video` | ffmpeg output; only `overlay`/`fade`/`crop` needed — never `drawtext`/`libass` |
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
### Scheduling: tituli owns it, or you do — no middle
|
|
118
|
+
|
|
119
|
+
`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.
|
|
120
|
+
|
|
121
|
+
## Seams
|
|
122
|
+
|
|
123
|
+
| # | seam | v1 default (no new dependency) | replacement you can point at |
|
|
124
|
+
|---|---|---|---|
|
|
125
|
+
| 1 | subject avoidance — `Frame.from_image(avoid=)` | none (boxes you pass) | `burns.salient_box`, any `burns.FacesDetector` (`pip install tituli[saliency]`) |
|
|
126
|
+
| 2 | the rasteriser — `render(engine=)` | `PillowEngine` (FreeType via Pillow, bitmap rotation) | `HarfBuzzEngine` — shaping + outline transforms (`pip install tituli[shaping]`) |
|
|
127
|
+
| 3 | the shape — `on_path(shape=)` | named presets | an SVG `d` string or any `Path` (a traced outline) |
|
|
128
|
+
| 4 | delivery zones — `Frame(delivery=)` | none | `DELIVERY_RESERVED["youtube"]`; add a target, not a rule |
|
|
129
|
+
| 5 | the graph — `tituli.bodies` | plain dict from `body_for()` | `annot://schema/text-overlay/v1` registered with lacing (`pip install tituli[lacing]`) |
|
|
130
|
+
|
|
131
|
+
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`).
|
|
132
|
+
NOT seams: the scrim design, the anchor grid, the type ramp, the ffmpeg encode args — written directly, on purpose.
|
|
133
|
+
|
|
134
|
+
## Where this sits in the fleet
|
|
135
|
+
|
|
136
|
+
- **`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).
|
|
137
|
+
- **`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.
|
|
138
|
+
- **`braidio.video.credits_card`** is the plain-list case: `Credits.from_lines(lines)` → `credits_cards`, same never-truncate rule, designed type.
|
|
139
|
+
- **`burns`** owns saliency and the Ken Burns move; tituli only consumes `salient_box`.
|
|
140
|
+
- **`mixing`** owns subtitle (SRT) burn-in; tituli does not do subtitles.
|
|
141
|
+
- 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.
|
|
142
|
+
|
|
143
|
+
### Linked artifacts (lacing)
|
|
144
|
+
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
## Style defaults (why they look right)
|
|
148
|
+
|
|
149
|
+
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.
|
|
150
|
+
|
|
151
|
+
## Optional extras
|
|
152
|
+
|
|
153
|
+
| extra | adds |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `shaping` | `uharfbuzz` + `freetype-py`: ligatures, kerning, complex scripts, outline rotation |
|
|
156
|
+
| `saliency` | `burns` for `avoid=salient_box` |
|
|
157
|
+
| `lacing` | the body schema |
|
|
158
|
+
| `cli` | `cw` for `python -m tituli` |
|
|
159
|
+
|
|
160
|
+
## Skills
|
|
161
|
+
|
|
162
|
+
`gh skill install thorwhalen/tituli tituli` — or, after `pip install`, link `tituli/data/skills/tituli` into your agent's skills directory.
|
tituli-0.0.2/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# tituli
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install tituli # Pillow only; ffmpeg on PATH for video output
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from tituli import Frame, title_card, caption, render
|
|
11
|
+
|
|
12
|
+
frame = Frame.blank((1920, 1080), color="#101014")
|
|
13
|
+
render(
|
|
14
|
+
title_card("Il pleut", "Apollinaire, 1918", kicker="Episode 3", frame=frame), frame
|
|
15
|
+
).save("title.png")
|
|
16
|
+
|
|
17
|
+
frame = Frame.from_image(
|
|
18
|
+
"still.jpg", delivery="youtube"
|
|
19
|
+
) # add avoid=burns.salient_box to keep off the subject
|
|
20
|
+
render(
|
|
21
|
+
caption("Eliza Hamilton", "Ralph Earl, 1787 · public domain", frame=frame), frame
|
|
22
|
+
).save("captioned.png")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or from the shell (`pip install tituli[cli]`):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python -m tituli title_card "The Apple" "a concrete poem" --background "#101014" --out title.png
|
|
29
|
+
python -m tituli credits credits.json --mode crawl --out credits.mp4
|
|
30
|
+
python -m tituli caption still.jpg "Angelica Schuyler Church" "John Trumbull, 1785" --out cap.png
|
|
31
|
+
python -m tituli calligram "round and round the apple goes" --shape circle --out calligram.png
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## One model
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
### The frame-knowledge ladder
|
|
39
|
+
|
|
40
|
+
"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:
|
|
41
|
+
|
|
42
|
+
| rung | the Frame knows | so the engine can |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| a | its size | place by anchor in the title-safe area; assume the worst → white on a dark scrim |
|
|
45
|
+
| b | a solid colour (`Frame.blank(size, color=…)`) | pick ink by WCAG contrast; no scrim |
|
|
46
|
+
| 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 |
|
|
47
|
+
| d | c + regions to keep clear (`avoid=`) | rank the nine anchor positions by how little they cover the subject |
|
|
48
|
+
|
|
49
|
+
`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.
|
|
50
|
+
|
|
51
|
+
### Pipeline order (it matters)
|
|
52
|
+
|
|
53
|
+
**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`).
|
|
54
|
+
|
|
55
|
+
## What you get
|
|
56
|
+
|
|
57
|
+
| call | what it makes |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `title_card(title, subtitle, kicker=…, frame=…)` | opening card; title fitted (shrunk, never clipped) to 80 % of the safe width |
|
|
60
|
+
| `caption(text, attribution, frame=…)` | museum label: what's on screen + a small credit line; subject-avoiding; corner-falloff scrim |
|
|
61
|
+
| `lower_third(name, role, frame=…)` | who is speaking |
|
|
62
|
+
| `note(lines, headline=…, frame=…)` | editorial context: a headline over equal-weight lines ("what *Hamilton* is") |
|
|
63
|
+
| `intertitle(text, frame=…)` | silent-film card, serif italic |
|
|
64
|
+
| `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) |
|
|
65
|
+
| `on_path(text, shape="circle"\|"wave"\|SVG d\|Path)` | glyphs riding any path, rotated to the tangent or kept upright |
|
|
66
|
+
| `rain(lines)` | Apollinaire's *Il pleut*: upright letters stepping down fanning streaks (the 1918 measurements as defaults) |
|
|
67
|
+
| `in_shape(text, mask)` | prose poured into a silhouette |
|
|
68
|
+
| `schedule_labels(spans, label_for, suppressed_by=cards)` | one label per shot with first-appearance, repeat-gap and suppression rules **inside** the loop |
|
|
69
|
+
| `tituli.video.still / overlay / crawl / frames_to_video` | ffmpeg output; only `overlay`/`fade`/`crop` needed — never `drawtext`/`libass` |
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
### Scheduling: tituli owns it, or you do — no middle
|
|
74
|
+
|
|
75
|
+
`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.
|
|
76
|
+
|
|
77
|
+
## Seams
|
|
78
|
+
|
|
79
|
+
| # | seam | v1 default (no new dependency) | replacement you can point at |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| 1 | subject avoidance — `Frame.from_image(avoid=)` | none (boxes you pass) | `burns.salient_box`, any `burns.FacesDetector` (`pip install tituli[saliency]`) |
|
|
82
|
+
| 2 | the rasteriser — `render(engine=)` | `PillowEngine` (FreeType via Pillow, bitmap rotation) | `HarfBuzzEngine` — shaping + outline transforms (`pip install tituli[shaping]`) |
|
|
83
|
+
| 3 | the shape — `on_path(shape=)` | named presets | an SVG `d` string or any `Path` (a traced outline) |
|
|
84
|
+
| 4 | delivery zones — `Frame(delivery=)` | none | `DELIVERY_RESERVED["youtube"]`; add a target, not a rule |
|
|
85
|
+
| 5 | the graph — `tituli.bodies` | plain dict from `body_for()` | `annot://schema/text-overlay/v1` registered with lacing (`pip install tituli[lacing]`) |
|
|
86
|
+
|
|
87
|
+
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`).
|
|
88
|
+
NOT seams: the scrim design, the anchor grid, the type ramp, the ffmpeg encode args — written directly, on purpose.
|
|
89
|
+
|
|
90
|
+
## Where this sits in the fleet
|
|
91
|
+
|
|
92
|
+
- **`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).
|
|
93
|
+
- **`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.
|
|
94
|
+
- **`braidio.video.credits_card`** is the plain-list case: `Credits.from_lines(lines)` → `credits_cards`, same never-truncate rule, designed type.
|
|
95
|
+
- **`burns`** owns saliency and the Ken Burns move; tituli only consumes `salient_box`.
|
|
96
|
+
- **`mixing`** owns subtitle (SRT) burn-in; tituli does not do subtitles.
|
|
97
|
+
- 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.
|
|
98
|
+
|
|
99
|
+
### Linked artifacts (lacing)
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
## Style defaults (why they look right)
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
## Optional extras
|
|
108
|
+
|
|
109
|
+
| extra | adds |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `shaping` | `uharfbuzz` + `freetype-py`: ligatures, kerning, complex scripts, outline rotation |
|
|
112
|
+
| `saliency` | `burns` for `avoid=salient_box` |
|
|
113
|
+
| `lacing` | the body schema |
|
|
114
|
+
| `cli` | `cw` for `python -m tituli` |
|
|
115
|
+
|
|
116
|
+
## Skills
|
|
117
|
+
|
|
118
|
+
`gh skill install thorwhalen/tituli tituli` — or, after `pip install`, link `tituli/data/skills/tituli` into your agent's skills directory.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2026-09-14 — 0.0.1
|
|
4
|
+
|
|
5
|
+
First release. Frame-knowledge ladder (`Frame`), `Layout`/`Run`/`Plate` model, Pillow renderer with rotated glyph tiles, optional HarfBuzz+FreeType engine, title card / caption / lower third / note / intertitle, structured credits (cards + crawl, never truncating), calligrams (`on_path`, `rain`, `in_shape`), scheduler with first-appearance / repeat-gap / suppression inside the loop, ffmpeg output (`still`, `overlay`, `crawl`, `frames_to_video`), CLI via `cw`, lacing body `annot://schema/text-overlay/v1`, shipped `tituli` skill.
|