tituli 0.0.2__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,127 @@
1
+ """tituli — text in video: title cards, credits, captions and calligrams.
2
+
3
+ One model underneath: a :class:`Layout` of placed :class:`Run`s on a
4
+ :class:`Frame` that knows as much (or as little) about the picture as you tell
5
+ it. Everything else is a convenience over that.
6
+
7
+ Quick start::
8
+
9
+ from tituli import Frame, title_card, caption, credits_crawl, on_path, render
10
+
11
+ frame = Frame.blank((1920, 1080), color="#101014")
12
+ render(title_card("Il pleut", "Apollinaire, 1918", frame=frame), frame).save("title.png")
13
+
14
+ frame = Frame.from_image("still.jpg", delivery="youtube") # add avoid=burns.salient_box
15
+ render(caption("Eliza Hamilton", "Ralph Earl, 1787 · public domain", frame=frame), frame).save("cap.png")
16
+
17
+ Optional layers, none imported here: ``tituli[shaping]`` (HarfBuzz engine),
18
+ ``tituli[saliency]`` (``burns`` subject avoidance), ``tituli[lacing]`` (the
19
+ text-overlay body schema), ``tituli[cli]`` (``python -m tituli``).
20
+ """
21
+
22
+ from tituli.calligram import in_shape, on_path, rain, resolve_shape
23
+ from tituli.color import contrast_ratio, ink_for, parse_color
24
+ from tituli.compose import (
25
+ caption,
26
+ decide_ink,
27
+ intertitle,
28
+ lower_third,
29
+ note,
30
+ title_card,
31
+ truncate,
32
+ )
33
+ from tituli.credits import (
34
+ Credits,
35
+ CreditsStyle,
36
+ Entry,
37
+ Section,
38
+ credits_cards,
39
+ credits_crawl,
40
+ credits_frame,
41
+ )
42
+ from tituli.fonts import Face, families, find_font, resolve_face
43
+ from tituli.frame import DELIVERY_RESERVED, Frame, cover_fit, reserved_zones
44
+ from tituli.geometry import ANCHORS, TITLE_SAFE, Box, Path, safe_area
45
+ from tituli.layout import Layout, Plate, Run, along_path, block, fit_size, measure, wrap
46
+ from tituli.render import frames, make_engine, render, render_overlay
47
+ from tituli.schedule import (
48
+ UNLABELLED,
49
+ Label,
50
+ Span,
51
+ TimedOverlay,
52
+ resolve,
53
+ schedule_labels,
54
+ )
55
+ from tituli.style import (
56
+ ATTRIBUTION,
57
+ CALLIGRAM,
58
+ CAPTION,
59
+ CREDITS_NAME,
60
+ KICKER,
61
+ SUBTITLE,
62
+ TITLE,
63
+ TextStyle,
64
+ )
65
+
66
+ __all__ = [
67
+ "Frame",
68
+ "Layout",
69
+ "Run",
70
+ "Plate",
71
+ "Box",
72
+ "Path",
73
+ "TextStyle",
74
+ "Face",
75
+ "title_card",
76
+ "caption",
77
+ "lower_third",
78
+ "note",
79
+ "intertitle",
80
+ "truncate",
81
+ "decide_ink",
82
+ "Credits",
83
+ "CreditsStyle",
84
+ "Entry",
85
+ "Section",
86
+ "credits_cards",
87
+ "credits_crawl",
88
+ "credits_frame",
89
+ "on_path",
90
+ "rain",
91
+ "in_shape",
92
+ "resolve_shape",
93
+ "block",
94
+ "along_path",
95
+ "wrap",
96
+ "measure",
97
+ "fit_size",
98
+ "render",
99
+ "render_overlay",
100
+ "frames",
101
+ "make_engine",
102
+ "Label",
103
+ "Span",
104
+ "TimedOverlay",
105
+ "UNLABELLED",
106
+ "schedule_labels",
107
+ "resolve",
108
+ "families",
109
+ "find_font",
110
+ "resolve_face",
111
+ "contrast_ratio",
112
+ "ink_for",
113
+ "parse_color",
114
+ "safe_area",
115
+ "ANCHORS",
116
+ "TITLE_SAFE",
117
+ "DELIVERY_RESERVED",
118
+ "reserved_zones",
119
+ "cover_fit",
120
+ "TITLE",
121
+ "SUBTITLE",
122
+ "KICKER",
123
+ "CAPTION",
124
+ "ATTRIBUTION",
125
+ "CREDITS_NAME",
126
+ "CALLIGRAM",
127
+ ]
tituli/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m tituli`` — the CLI over :data:`tituli.tools._dispatch_funcs`."""
2
+
3
+ from tituli.tools import _dispatch_funcs
4
+
5
+ if __name__ == "__main__":
6
+ import cw
7
+
8
+ raise SystemExit(cw.dispatch(_dispatch_funcs))
tituli/bodies.py ADDED
@@ -0,0 +1,119 @@
1
+ """A lacing body schema for a rendered caption (``pip install tituli[lacing]``).
2
+
3
+ URI: ``annot://schema/text-overlay/v1``. A caption is an annotation **on the
4
+ image** (``MediaRef(asset_id=<image hash>)``), and when that image sits under an
5
+ audio segment the pair is expressed the way ``artful.PanelBody`` does it: the
6
+ annotation's ``reference`` is the interval on the segment and
7
+ ``provenance.was_derived_from`` lists both the image ``asset_id`` and the
8
+ caption annotation id. No N-ary reference type is invented; the timing lives
9
+ on the reference, not in this body.
10
+
11
+ Registration is lazy and idempotent (:func:`register`), so ``import tituli``
12
+ never touches lacing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ TEXT_OVERLAY_V1 = "annot://schema/text-overlay/v1"
20
+ _REGISTERED = False
21
+
22
+
23
+ def _model():
24
+ from pydantic import BaseModel, Field
25
+
26
+ class TextOverlayBodyV1(BaseModel):
27
+ """What was written over the picture, and how. Placement is normalised."""
28
+
29
+ model_config = {"frozen": True, "extra": "forbid"}
30
+
31
+ text: str = Field(
32
+ ..., description="The caption as displayed (after wrapping/truncation)."
33
+ )
34
+ attribution: str = Field(
35
+ "", description="The small credit line under the caption, if any."
36
+ )
37
+ kind: str = Field(
38
+ "caption",
39
+ description="Free string: 'caption', 'lower_third', 'title', 'intertitle', ...",
40
+ )
41
+ anchor: str = Field(
42
+ "", description="Grid position the block was placed at ('top-left', ...)."
43
+ )
44
+ box: tuple[float, float, float, float] | None = Field(
45
+ None, description="Normalised (x, y, w, h) of the text block in the frame."
46
+ )
47
+ ink: str | None = Field(None, description="Ink colour as #rrggbb, if recorded.")
48
+ scrim: bool | None = Field(
49
+ None, description="Whether a scrim was drawn under the text."
50
+ )
51
+ reason: str | None = Field(
52
+ None, description="The ink/scrim decision the frame's knowledge led to."
53
+ )
54
+ style: dict[str, Any] | None = Field(
55
+ None, description="The TextStyle fields used, for re-rendering."
56
+ )
57
+ unlabelled: bool = Field(
58
+ False,
59
+ description="True when the still was deliberately shown without a label (an explicit choice, not an omission).",
60
+ )
61
+ overlay_asset_id: str | None = Field(
62
+ None, description="asset_id of the rendered transparent PNG, if stored."
63
+ )
64
+
65
+ return TextOverlayBodyV1
66
+
67
+
68
+ def register() -> str:
69
+ """Register the schema with lacing (once). Returns the URI."""
70
+ global _REGISTERED
71
+ if _REGISTERED:
72
+ return TEXT_OVERLAY_V1
73
+ from lacing import register_body_schema
74
+
75
+ register_body_schema(TEXT_OVERLAY_V1, _model())
76
+ _REGISTERED = True
77
+ return TEXT_OVERLAY_V1
78
+
79
+
80
+ def body_for(
81
+ layout,
82
+ frame,
83
+ *,
84
+ text: str,
85
+ attribution: str = "",
86
+ kind: str = "caption",
87
+ unlabelled: bool = False,
88
+ ) -> dict:
89
+ """The body dict for a rendered layout — plain data, no lacing needed.
90
+
91
+ >>> from tituli.frame import Frame
92
+ >>> from tituli.compose import caption
93
+ >>> f = Frame.blank((1920, 1080))
94
+ >>> b = body_for(caption("A still", "PD", frame=f), f, text="A still", attribution="PD")
95
+ >>> b["kind"], b["anchor"], len(b["box"])
96
+ ('caption', 'bottom-left', 4)
97
+ """
98
+ bb = layout.bbox()
99
+ ink = None
100
+ if layout.runs:
101
+ r, g, b, _ = layout.runs[0].color
102
+ ink = f"#{r:02x}{g:02x}{b:02x}"
103
+ return {
104
+ "text": text,
105
+ "attribution": attribution,
106
+ "kind": kind,
107
+ "anchor": str(layout.meta.get("anchor", "")),
108
+ "box": tuple(round(v, 4) for v in bb.to_norm(frame.width, frame.height)),
109
+ "ink": ink,
110
+ "scrim": any(p.kind != "box" or p.color[3] < 255 for p in layout.plates)
111
+ or None,
112
+ "reason": layout.meta.get("ink"),
113
+ "style": None,
114
+ "unlabelled": unlabelled,
115
+ "overlay_asset_id": None,
116
+ }
117
+
118
+
119
+ __all__ = ["TEXT_OVERLAY_V1", "register", "body_for"]
tituli/calligram.py ADDED
@@ -0,0 +1,164 @@
1
+ """Calligrams and concrete poems: text whose shape is part of the meaning.
2
+
3
+ Three treatments, all producing an ordinary :class:`~tituli.layout.Layout`:
4
+
5
+ * :func:`on_path` — glyphs ride a :class:`~tituli.geometry.Path` (any shape:
6
+ circle, wave, Bézier, SVG ``d``), rotated to the tangent or kept upright;
7
+ * :func:`rain` — Apollinaire's *Il pleut*: upright letters stepping down
8
+ fanning streaks (a preset of the same machinery, with the 1918 measurements
9
+ as defaults);
10
+ * :func:`in_shape` — prose poured into a silhouette, the concrete-page case.
11
+
12
+ ``shape=`` accepts a named preset, an SVG path string, or a
13
+ :class:`~tituli.geometry.Path`, so a caller can say ``"circle"`` and a
14
+ designer can hand over a traced outline — the same seam.
15
+
16
+ >>> from tituli.frame import Frame
17
+ >>> lay = on_path("round and round", shape="circle", frame=Frame.blank((800, 800)))
18
+ >>> len(lay.runs) == len("round and round".replace(" ", ""))
19
+ True
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Literal, Sequence
25
+
26
+ from tituli.frame import Frame
27
+ from tituli.geometry import Box, Path
28
+ from tituli.layout import Layout, along_path, fill_shape, glyph_columns
29
+ from tituli.style import CALLIGRAM, TextStyle
30
+
31
+ # Measured from the 1918 Mercure de France setting of "Il pleut".
32
+ IL_PLEUT_SLANTS: tuple[float, ...] = (0.186, 0.220, 0.257, 0.298, 0.353)
33
+ IL_PLEUT_HEAD_OFFSETS: tuple[float, ...] = (0.0, 7.0, 11.7, 16.4, 19.3)
34
+ IL_PLEUT_SIZE_RATIO = 0.86
35
+
36
+ _SHAPE_INSET = 0.12 # fraction of the safe box kept clear around a shape
37
+ _WAVE_AMPLITUDE = 0.12 # of the box height
38
+ _ARC_SWEEP = (200.0, 340.0) # a gentle smile-shaped arc, degrees (screen coords)
39
+
40
+ ShapeSpec = "str | Path"
41
+
42
+
43
+ def resolve_shape(shape: "str | Path", box: Box) -> Path:
44
+ """Turn a name, an SVG ``d`` string or a Path into a Path fitted to ``box``.
45
+
46
+ Names: ``"line"``, ``"circle"``, ``"arc"``, ``"wave"``, ``"diagonal"``,
47
+ ``"s-curve"``. Anything starting with ``M``/``m`` is parsed as SVG.
48
+ """
49
+ if isinstance(shape, Path):
50
+ return shape.fit(box)
51
+ name = shape.strip()
52
+ if name[:1] in "Mm" and any(c.isdigit() for c in name):
53
+ return Path.from_svg(name).fit(box)
54
+ cx, cy = box.center
55
+ r = min(box.width, box.height) / 2
56
+ if name == "line":
57
+ return Path.line((box.x0, cy), (box.x1, cy))
58
+ if name == "circle":
59
+ return Path.circle((cx, cy), r)
60
+ if name == "arc":
61
+ return Path.arc((cx, cy + r * 0.6), r * 1.1, *_ARC_SWEEP).fit(box)
62
+ if name == "wave":
63
+ return Path.wave(
64
+ (box.x0, cy),
65
+ (box.x1, cy),
66
+ amplitude=box.height * _WAVE_AMPLITUDE,
67
+ cycles=1.0,
68
+ )
69
+ if name == "diagonal":
70
+ return Path.line((box.x0, box.y0), (box.x1, box.y1))
71
+ if name == "s-curve":
72
+ return Path.bezier(
73
+ (box.x0, box.y1),
74
+ (box.x0 + box.width * 0.9, box.y0 + box.height * 0.9),
75
+ (box.x1 - box.width * 0.9, box.y0 + box.height * 0.1),
76
+ (box.x1, box.y0),
77
+ )
78
+ raise ValueError(
79
+ f"unknown shape {shape!r}; use a preset name, an SVG path string, or a Path"
80
+ )
81
+
82
+
83
+ def on_path(
84
+ text: str,
85
+ *,
86
+ shape: "str | Path" = "wave",
87
+ frame: Frame,
88
+ style: TextStyle = CALLIGRAM,
89
+ box: Box | None = None,
90
+ upright: bool = False,
91
+ align: Literal["start", "center", "end"] = "center",
92
+ fit_text: bool = True,
93
+ ) -> Layout:
94
+ """Set ``text`` along a shape inside ``box`` (default: the safe area, inset).
95
+
96
+ With ``fit_text`` the type is shrunk until the whole string fits the path
97
+ length (never clipped); the resulting size is in ``meta["size"]``.
98
+ """
99
+ box = box or frame.safe.inset(
100
+ frame.safe.width * _SHAPE_INSET, frame.safe.height * _SHAPE_INSET
101
+ )
102
+ path = resolve_shape(shape, box)
103
+ st = style
104
+ lay = along_path(text, path, st, frame, upright=upright, align=align)
105
+ while fit_text and lay.meta.get("overflow", 0) > 0 and st.size > 0.008:
106
+ st = st.with_(size=st.size * 0.92)
107
+ lay = along_path(text, path, st, frame, upright=upright, align=align)
108
+ return Layout(
109
+ lay.runs, lay.plates, {**lay.meta, "size": st.size, "path_length": path.length}
110
+ )
111
+
112
+
113
+ def rain(
114
+ lines: Sequence[str] | str,
115
+ *,
116
+ frame: Frame,
117
+ style: TextStyle = CALLIGRAM,
118
+ box: Box | None = None,
119
+ slants: Sequence[float] = IL_PLEUT_SLANTS,
120
+ head_offsets: Sequence[float] = IL_PLEUT_HEAD_OFFSETS,
121
+ size_ratio: float = IL_PLEUT_SIZE_RATIO,
122
+ ) -> Layout:
123
+ """*Il pleut*: each line a streak of upright letters falling across the frame.
124
+
125
+ Wants a portrait frame. ``slants`` (dx per step down) and ``head_offsets``
126
+ (where each streak starts, in slot units) are shape parameters, not
127
+ coordinates — the defaults are Apollinaire's.
128
+ """
129
+ if isinstance(lines, str):
130
+ lines = [l for l in lines.split("\n") if l.strip()]
131
+ box = box or frame.safe
132
+ return glyph_columns(
133
+ list(lines),
134
+ style,
135
+ frame,
136
+ box=box,
137
+ slants=slants,
138
+ head_offsets=head_offsets,
139
+ size_ratio=size_ratio,
140
+ )
141
+
142
+
143
+ def in_shape(
144
+ text: str,
145
+ mask,
146
+ *,
147
+ frame: Frame,
148
+ style: TextStyle = CALLIGRAM,
149
+ box: Box | None = None,
150
+ repeat: bool = True,
151
+ ) -> Layout:
152
+ """Pour prose into a silhouette (light = inside). See :func:`tituli.layout.fill_shape`."""
153
+ box = box or frame.safe
154
+ return fill_shape(text, mask, style, frame, box=box, repeat=repeat)
155
+
156
+
157
+ __all__ = [
158
+ "on_path",
159
+ "rain",
160
+ "in_shape",
161
+ "resolve_shape",
162
+ "IL_PLEUT_SLANTS",
163
+ "IL_PLEUT_HEAD_OFFSETS",
164
+ ]
tituli/color.py ADDED
@@ -0,0 +1,158 @@
1
+ """Colours, WCAG contrast, and the ink-for-this-background decision.
2
+
3
+ Everything here is pure arithmetic on ``(r, g, b[, a])`` tuples in 0–255, so it
4
+ costs nothing to import and is easy to test.
5
+
6
+ >>> contrast_ratio((255, 255, 255), (0, 0, 0))
7
+ 21.0
8
+ >>> ink_for(luminance=0.9) # light background -> dark ink
9
+ (17, 17, 17)
10
+ >>> ink_for(luminance=0.1) # dark background -> light ink
11
+ (255, 255, 255)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Sequence, Union
17
+
18
+ RGB = tuple[int, int, int]
19
+ RGBA = tuple[int, int, int, int]
20
+ Color = Union[RGB, RGBA, str]
21
+
22
+ WHITE: RGB = (255, 255, 255)
23
+ BLACK: RGB = (0, 0, 0)
24
+ NEAR_BLACK: RGB = (17, 17, 17) # #111 — reads as black without the harshness
25
+ OFF_WHITE: RGB = (245, 245, 243) # a warm paper white for solid title cards
26
+ CREDITS_BLACK: RGB = (8, 8, 10)
27
+
28
+ # WCAG 2.x thresholds (AA): 4.5:1 for normal text, 3:1 for large text
29
+ WCAG_NORMAL_MIN = 4.5
30
+ WCAG_LARGE_MIN = 3.0
31
+ # "Large" text per WCAG is >= 18pt regular or >= 14pt bold; on a 1080p canvas
32
+ # every tituli default is well above that, so 3:1 is the operative floor and
33
+ # 4.5:1 is what we *aim* for.
34
+
35
+ _SRGB_THRESHOLD = 0.03928
36
+ _SRGB_LINEAR_DIVISOR = 12.92
37
+ _SRGB_GAMMA = 2.4
38
+ _SRGB_OFFSET = 0.055
39
+ _SRGB_SCALE = 1.055
40
+ _LUMA_R, _LUMA_G, _LUMA_B = 0.2126, 0.7152, 0.0722
41
+ _CONTRAST_OFFSET = 0.05
42
+
43
+
44
+ def parse_color(color: Color) -> RGBA:
45
+ """Accept ``"#rgb"``, ``"#rrggbb"``, ``"#rrggbbaa"``, a Pillow name or a tuple.
46
+
47
+ >>> parse_color("#fff")
48
+ (255, 255, 255, 255)
49
+ >>> parse_color((10, 20, 30))
50
+ (10, 20, 30, 255)
51
+ """
52
+ if isinstance(color, str):
53
+ s = color.strip()
54
+ if s.startswith("#"):
55
+ h = s[1:]
56
+ if len(h) in (3, 4):
57
+ h = "".join(c * 2 for c in h)
58
+ if len(h) == 6:
59
+ h += "ff"
60
+ if len(h) != 8:
61
+ raise ValueError(f"bad hex colour: {color!r}")
62
+ return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4, 6)) # type: ignore[return-value]
63
+ from PIL import ImageColor
64
+
65
+ return ImageColor.getcolor(s, "RGBA") # type: ignore[return-value]
66
+ t = tuple(int(c) for c in color)
67
+ if len(t) == 3:
68
+ return (*t, 255) # type: ignore[return-value]
69
+ if len(t) == 4:
70
+ return t # type: ignore[return-value]
71
+ raise ValueError(f"bad colour tuple: {color!r}")
72
+
73
+
74
+ def _channel(c: int) -> float:
75
+ v = c / 255.0
76
+ if v <= _SRGB_THRESHOLD:
77
+ return v / _SRGB_LINEAR_DIVISOR
78
+ return ((v + _SRGB_OFFSET) / _SRGB_SCALE) ** _SRGB_GAMMA
79
+
80
+
81
+ def relative_luminance(color: Color) -> float:
82
+ """WCAG relative luminance in 0..1.
83
+
84
+ >>> round(relative_luminance((255, 255, 255)), 3)
85
+ 1.0
86
+ >>> relative_luminance((0, 0, 0))
87
+ 0.0
88
+ """
89
+ r, g, b, _ = parse_color(color)
90
+ return _LUMA_R * _channel(r) + _LUMA_G * _channel(g) + _LUMA_B * _channel(b)
91
+
92
+
93
+ def contrast_ratio(a: Color, b: Color) -> float:
94
+ """WCAG contrast ratio between two colours (1..21).
95
+
96
+ >>> round(contrast_ratio("#777", "#fff"), 2)
97
+ 4.48
98
+ """
99
+ la, lb = relative_luminance(a), relative_luminance(b)
100
+ hi, lo = max(la, lb), min(la, lb)
101
+ return round((hi + _CONTRAST_OFFSET) / (lo + _CONTRAST_OFFSET), 4)
102
+
103
+
104
+ def contrast_from_luminance(ink: Color, luminance: float) -> float:
105
+ """Contrast of ``ink`` over a background of known relative luminance."""
106
+ li = relative_luminance(ink)
107
+ hi, lo = max(li, luminance), min(li, luminance)
108
+ return (hi + _CONTRAST_OFFSET) / (lo + _CONTRAST_OFFSET)
109
+
110
+
111
+ def ink_for(
112
+ *,
113
+ luminance: float,
114
+ light: RGB = WHITE,
115
+ dark: RGB = NEAR_BLACK,
116
+ ) -> RGB:
117
+ """Pick the ink (light or dark) with more contrast against ``luminance``."""
118
+ if contrast_from_luminance(light, luminance) >= contrast_from_luminance(
119
+ dark, luminance
120
+ ):
121
+ return light
122
+ return dark
123
+
124
+
125
+ def needs_scrim(
126
+ ink: Color, luminance: float, *, minimum: float = WCAG_NORMAL_MIN
127
+ ) -> bool:
128
+ """Whether ``ink`` over that background falls short of ``minimum`` contrast.
129
+
130
+ >>> needs_scrim((255, 255, 255), luminance=0.5)
131
+ True
132
+ >>> needs_scrim((255, 255, 255), luminance=0.02)
133
+ False
134
+ """
135
+ return contrast_from_luminance(ink, luminance) < minimum
136
+
137
+
138
+ def mix(a: Color, b: Color, t: float) -> RGBA:
139
+ """Linear blend, ``t=0`` -> ``a``, ``t=1`` -> ``b``."""
140
+ pa, pb = parse_color(a), parse_color(b)
141
+ return tuple(round(x + (y - x) * t) for x, y in zip(pa, pb)) # type: ignore[return-value]
142
+
143
+
144
+ def with_alpha(color: Color, alpha: float) -> RGBA:
145
+ """Same colour with opacity ``alpha`` in 0..1.
146
+
147
+ >>> with_alpha("#000", 0.5)
148
+ (0, 0, 0, 128)
149
+ """
150
+ r, g, b, _ = parse_color(color)
151
+ return (r, g, b, round(255 * alpha))
152
+
153
+
154
+ def mean_luminance(pixels: Sequence[RGB]) -> float:
155
+ """Mean relative luminance of a pixel sample."""
156
+ if not pixels:
157
+ return 0.0
158
+ return sum(relative_luminance(p) for p in pixels) / len(pixels)