Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions braidio/transforms/_video_cut.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions tests/test_video_transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1673,6 +1673,111 @@ 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_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
):
Expand Down
Loading