From 5514a3e81dc36b614b4aa79af06b9b5b41a448af Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:08:57 +0000 Subject: [PATCH 1/2] Wrap a still's caption to more lines on a narrow frame instead of refusing the cut (#77) A still's subject is shared by every cut, but legibility is per-delivery: a 9:16 cut has half the width per line of a 16:9 one and twice the height. A caption tituli cannot set in its default line budget now gets a budget scaled by the frame's aspect (capped), shrink-then-wrap as tituli already does. Captions that fit are untouched; ones that still do not fit raise as before. Co-Authored-By: Claude Sonnet 5.5 --- braidio/transforms/_video_cut.py | 78 ++++++++++++++++++++++++++++++++ tests/test_video_transforms.py | 64 ++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/braidio/transforms/_video_cut.py b/braidio/transforms/_video_cut.py index 5790242c..5edc9edc 100644 --- a/braidio/transforms/_video_cut.py +++ b/braidio/transforms/_video_cut.py @@ -119,6 +119,13 @@ DEFAULT_CREDITS_S = 8.0 #: Default caption wrap (a player's two lines). DEFAULT_CAPTION_CHARS = 42 +#: The frame a label's default line budget is sized for: tituli's 3 lines +#: fit a 16:9 frame. A narrower frame has the height for more lines, just not +#: the width, so its budget grows in proportion (thorwhalen/braidio#77). +_LABEL_REFERENCE_ASPECT = 16 / 9 +#: The most lines a still's caption may wrap to on a narrow frame. Past this +#: the caption is a paragraph, not a label, and the author should shorten it. +_MAX_LABEL_LINES = 6 #: Default delivery for tituli's reserved zones (the subtitle band + controls). DEFAULT_DELIVERY = "youtube" #: The tituli slot each label-track kind lands in when the body names none. @@ -1286,6 +1293,74 @@ def _card_named_by(text, overlays) -> str: return "an overlay tituli could not identify" +def _label_lines_for(size, default: int) -> int: + """The line budget for a still's caption on a ``size`` frame. + + ``default`` (tituli's own budget) on any frame as wide as 16:9; more, in + proportion to how much narrower the frame is, up to ``_MAX_LABEL_LINES``. + + >>> _label_lines_for((1920, 1080), 3) + 3 + >>> _label_lines_for((1080, 1080), 3) + 6 + >>> _label_lines_for((1080, 1920), 3) + 6 + >>> _label_lines_for((1440, 1080), 3) + 4 + """ + width, height = size + scaled = math.ceil(default * _LABEL_REFERENCE_ASPECT * height / width) + return max(default, min(_MAX_LABEL_LINES, scaled)) + + +def _wrap_labels_for_frame(overlays, *, size, delivery): + """``overlays`` with each still caption tituli cannot set in its default + line budget given a taller budget instead (thorwhalen/braidio#77). + + A still's ``subject`` is one string shared by every cut of a production, + but legibility is per-delivery: type is sized as a fraction of frame + *height* and must fit the frame's *width*, so a 9:16 cut has about half the + room per line that a 16:9 cut has — and twice the lines to spend. The + least surprising behaviour is therefore the one tituli already has + (shrink, then wrap, never truncate) given the budget the frame can afford. + + Only a caption that would otherwise be refused is touched: one that fits + keeps its payload and so renders exactly as it always has. A caption that + does not fit even the taller budget still raises ``TextDoesNotFit`` (the + author shortens it; a dropped or ellipsised credit is a licence failure + nobody sees). Cards, and attributions (always two lines), are untouched. + """ + import inspect + + from tituli import Label + from tituli.compose import TextDoesNotFit, caption + from tituli.frame import Frame + + default = inspect.signature(caption).parameters["max_lines"].default + lines = _label_lines_for(size, default) + if lines <= default: + return list(overlays) + frame = Frame.blank(size).with_delivery(delivery) + out = [] + for overlay in overlays: + payload = overlay.payload + if overlay.layout is None and isinstance(payload, Label): + try: + caption(payload.text, payload.attribution, frame=frame, anchor=overlay.slot) + except TextDoesNotFit: + overlay = overlay.with_layout( + caption( + payload.text, + payload.attribution, + frame=frame, + anchor=overlay.slot, + max_lines=lines, + ) + ) + out.append(overlay) + return out + + def _caption_overflow_error(exc, overlays, *, size, delivery: str): """Re-raise a ``tituli.compose.TextDoesNotFit`` naming the still and the delivery it happened on (thorwhalen/braidio#77 item 4). @@ -1663,6 +1738,9 @@ def _composite( staged = staging / f"{skel.id}_labels.mp4" try: + overlays = _wrap_labels_for_frame( + overlays, size=size, delivery=settings["delivery"] + ) overlay( current, overlays, diff --git a/tests/test_video_transforms.py b/tests/test_video_transforms.py index b0153661..f6c692c5 100644 --- a/tests/test_video_transforms.py +++ b/tests/test_video_transforms.py @@ -1673,6 +1673,70 @@ def _overlay_overflows(video, overlays, dst, **kw): assert "could not identify" not in str(exc_info.value) +_LONG_SUBJECT = ( + "A Central Park concert audience, Schaefer Bandstand — not the 1971 " + "Beach Boys concert, a later crowd" +) + + +def _label_overlay(subject, key="a"): + from tituli import Label, TimedOverlay + + return TimedOverlay( + None, 0.0, 4.0, slot="top-left", payload=Label(subject, "Jane Doe, CC BY-SA 4.0", key=key) + ) + + +def test_a_tall_frame_wraps_a_caption_a_wide_frame_would_refuse(): + """thorwhalen/braidio#77: the same still that sets in 3 lines at 16:9 needs + 5 at 9:16. The tall frame has the height for them, so it gets them rather + than refusing the cut — and nothing is truncated.""" + pytest.importorskip("tituli") + from tituli.compose import TextDoesNotFit, caption + from tituli.frame import Frame + + from braidio.transforms._video_cut import _wrap_labels_for_frame + + overlay = _label_overlay(_LONG_SUBJECT) + vertical = Frame.blank((1080, 1920)).with_delivery("youtube") + with pytest.raises(TextDoesNotFit): # the premise: the default budget fails + caption(overlay.payload.text, overlay.payload.attribution, frame=vertical, anchor="top-left") + (wrapped,) = _wrap_labels_for_frame( + [overlay], size=(1080, 1920), delivery="youtube" + ) + assert wrapped.layout is not None + assert wrapped.payload is overlay.payload # still names its still for errors + # the whole subject is on the layout: wrapped, not ellipsised + assert "…" not in str(wrapped.layout) and "..." not in str(wrapped.layout) + + +def test_a_caption_that_already_fits_is_left_exactly_as_it_was(): + """Existing vertical cuts must render byte-identically: only an overlay + that would have been refused is given a layout.""" + pytest.importorskip("tituli") + from braidio.transforms._video_cut import _wrap_labels_for_frame + + short = _label_overlay("Central Park") + (out,) = _wrap_labels_for_frame([short], size=(1080, 1920), delivery="youtube") + assert out is short + long_ = _label_overlay(_LONG_SUBJECT) + (out,) = _wrap_labels_for_frame([long_], size=(1920, 1080), delivery="youtube") + assert out is long_ # a 16:9 frame has no taller budget to offer + + +def test_a_caption_too_long_even_for_the_taller_budget_still_raises(): + """Raise, never truncate: the wrap is a bigger budget, not a bypass.""" + pytest.importorskip("tituli") + from tituli.compose import TextDoesNotFit + + from braidio.transforms._video_cut import _wrap_labels_for_frame + + with pytest.raises(TextDoesNotFit): + _wrap_labels_for_frame( + [_label_overlay(_LONG_SUBJECT * 6)], size=(1080, 1920), delivery="youtube" + ) + + def test_motion_key_names_the_move_resolver( project, episode, stills, panels, monkeypatch ): From ac3b978388b6344176b3a93e1457839c52fb7ecb Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:11:27 +0000 Subject: [PATCH 2/2] Test the #77 wrap through video_cut.finish, not only the helper Co-Authored-By: Claude Sonnet 5.5 --- tests/test_video_transforms.py | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_video_transforms.py b/tests/test_video_transforms.py index f6c692c5..58aed0a7 100644 --- a/tests/test_video_transforms.py +++ b/tests/test_video_transforms.py @@ -1737,6 +1737,47 @@ def test_a_caption_too_long_even_for_the_taller_budget_still_raises(): ) +def test_a_vertical_finish_hands_tituli_the_wrapped_caption( + project, episode, stills, panels, patched_render, monkeypatch +): + """thorwhalen/braidio#77, through the transform: ``finish`` on a 1080x1920 + cut must give tituli a laid-out caption for a subject that overflows the + default budget. Guards the wiring, not just ``_wrap_labels_for_frame``.""" + pytest.importorskip("tituli") + import tituli.video + + from braidio.transforms import ( + VIDEO_CUT_FINISH_TRANSFORM, + VIDEO_CUT_RENDER_TRANSFORM, + ) + + still_a = stills[0] + _rewrite_in_place( + project, still_a, body={**still_a.body, "subject": _LONG_SUBJECT} + ) + motion = _run( + VIDEO_CUT_RENDER_TRANSFORM, + project, + *panels, + params={"size": (1080, 1920), "fps": 6}, + ).annotations[0] + seen = [] + + def _record(video, overlays, dst, **kw): + seen.extend(overlays) + raise RuntimeError("stop after the overlay hand-off") + + monkeypatch.setattr(tituli.video, "overlay", _record) + with pytest.raises(RuntimeError, match="hand-off"): + _run(VIDEO_CUT_FINISH_TRANSFORM, project, motion) + long_label = [ + o for o in seen if getattr(o.payload, "text", None) == _LONG_SUBJECT + ] + assert long_label and all(o.layout is not None for o in long_label) + short_label = [o for o in seen if getattr(o.payload, "text", None) == "Carol"] + assert short_label and all(o.layout is None for o in short_label) + + def test_motion_key_names_the_move_resolver( project, episode, stills, panels, monkeypatch ):