Skip to content

Commit a55c7b6

Browse files
JRemitzclaude
andcommitted
fix: include branding module and template that were missed in staging
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 87d3faa commit a55c7b6

3 files changed

Lines changed: 182 additions & 0 deletions

File tree

reeln/core/branding.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Branding overlay resolution and context building."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import os
7+
import tempfile
8+
from pathlib import Path
9+
10+
import reeln
11+
from reeln.core.errors import RenderError
12+
from reeln.core.log import get_logger
13+
from reeln.core.templates import format_ass_time, render_template_file
14+
from reeln.models.branding import BrandingConfig
15+
from reeln.models.template import TemplateContext
16+
17+
log: logging.Logger = get_logger(__name__)
18+
19+
20+
def build_branding_context(duration: float) -> TemplateContext:
21+
"""Build template context for the branding overlay.
22+
23+
Provides ``version`` (from ``reeln.__version__``) and
24+
``branding_end`` (ASS-formatted end timestamp).
25+
"""
26+
return TemplateContext(
27+
variables={
28+
"version": f"v{reeln.__version__}",
29+
"branding_end": format_ass_time(duration),
30+
}
31+
)
32+
33+
34+
def resolve_branding(config: BrandingConfig, output_dir: Path) -> Path | None:
35+
"""Resolve and render the branding template to a temp ``.ass`` file.
36+
37+
Returns ``None`` when branding is disabled. The caller is
38+
responsible for cleaning up the returned temp file.
39+
"""
40+
if not config.enabled:
41+
return None
42+
43+
ctx = build_branding_context(config.duration)
44+
45+
if config.template.startswith("builtin:"):
46+
from reeln.core.overlay import resolve_builtin_template
47+
48+
template_name = config.template.removeprefix("builtin:")
49+
template_path = resolve_builtin_template(template_name)
50+
else:
51+
template_path = Path(config.template).expanduser()
52+
53+
rendered = render_template_file(template_path, ctx)
54+
fd, tmp_path = tempfile.mkstemp(suffix=".ass", dir=str(output_dir))
55+
os.close(fd)
56+
tmp = Path(tmp_path)
57+
try:
58+
tmp.write_text(rendered, encoding="utf-8")
59+
except OSError as exc:
60+
tmp.unlink(missing_ok=True)
61+
raise RenderError(f"Failed to write rendered branding: {exc}") from exc
62+
return tmp

reeln/data/templates/branding.ass

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[Script Info]
2+
ScriptType: v4.00+
3+
PlayResX: 1080
4+
PlayResY: 1920
5+
6+
[V4+ Styles]
7+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
8+
Style: BrandText, Inter, 26, &H00FFFFFF, &H00FFFFFF, &H00000000, &H80000000, 1,0,0,0,100,100,1,0,1,3,2,8,0,0,0,1
9+
10+
[Events]
11+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
12+
Dialogue: 0,0:00:00.00,{{branding_end}},BrandText,,0,0,0,,{\fad(300,800)\an8\pos(540,100)}reeln {{version}} by https://streamn.dad

tests/unit/core/test_branding.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Tests for branding overlay resolution and context building."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from unittest.mock import patch
7+
8+
import pytest
9+
10+
import reeln
11+
from reeln.core.branding import build_branding_context, resolve_branding
12+
from reeln.core.errors import RenderError
13+
from reeln.core.templates import format_ass_time
14+
from reeln.models.branding import BrandingConfig
15+
16+
# ---------------------------------------------------------------------------
17+
# build_branding_context
18+
# ---------------------------------------------------------------------------
19+
20+
21+
class TestBuildBrandingContext:
22+
def test_version_included(self) -> None:
23+
ctx = build_branding_context(3.0)
24+
assert ctx.get("version") == f"v{reeln.__version__}"
25+
26+
def test_branding_end_format(self) -> None:
27+
ctx = build_branding_context(3.0)
28+
assert ctx.get("branding_end") == format_ass_time(3.0)
29+
30+
def test_custom_duration(self) -> None:
31+
ctx = build_branding_context(5.0)
32+
assert ctx.get("branding_end") == format_ass_time(5.0)
33+
34+
def test_zero_duration(self) -> None:
35+
ctx = build_branding_context(0.0)
36+
assert ctx.get("branding_end") == format_ass_time(0.0)
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# resolve_branding
41+
# ---------------------------------------------------------------------------
42+
43+
44+
class TestResolveBranding:
45+
def test_disabled_returns_none(self, tmp_path: Path) -> None:
46+
config = BrandingConfig(enabled=False)
47+
result = resolve_branding(config, tmp_path)
48+
assert result is None
49+
50+
def test_builtin_template_renders(self, tmp_path: Path) -> None:
51+
config = BrandingConfig()
52+
result = resolve_branding(config, tmp_path)
53+
assert result is not None
54+
assert result.is_file()
55+
assert result.suffix == ".ass"
56+
content = result.read_text(encoding="utf-8")
57+
assert f"v{reeln.__version__}" in content
58+
result.unlink()
59+
60+
def test_builtin_template_contains_fade(self, tmp_path: Path) -> None:
61+
config = BrandingConfig()
62+
result = resolve_branding(config, tmp_path)
63+
assert result is not None
64+
content = result.read_text(encoding="utf-8")
65+
assert "\\fad(300,800)" in content
66+
result.unlink()
67+
68+
def test_builtin_template_contains_branding_end(self, tmp_path: Path) -> None:
69+
config = BrandingConfig(duration=5.0)
70+
result = resolve_branding(config, tmp_path)
71+
assert result is not None
72+
content = result.read_text(encoding="utf-8")
73+
assert format_ass_time(5.0) in content
74+
result.unlink()
75+
76+
def test_custom_template(self, tmp_path: Path) -> None:
77+
template = tmp_path / "custom.ass"
78+
template.write_text(
79+
"[Script Info]\nScriptType: v4.00+\n\n[Events]\n"
80+
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
81+
"Dialogue: 0,0:00:00.00,{{branding_end}},Default,,0,0,0,,custom {{version}}\n",
82+
encoding="utf-8",
83+
)
84+
config = BrandingConfig(template=str(template))
85+
result = resolve_branding(config, tmp_path)
86+
assert result is not None
87+
content = result.read_text(encoding="utf-8")
88+
assert f"v{reeln.__version__}" in content
89+
assert format_ass_time(5.0) in content
90+
result.unlink()
91+
92+
def test_missing_custom_template(self, tmp_path: Path) -> None:
93+
config = BrandingConfig(template="/nonexistent/branding.ass")
94+
with pytest.raises(RenderError, match="Template file not found"):
95+
resolve_branding(config, tmp_path)
96+
97+
def test_missing_builtin_template(self, tmp_path: Path) -> None:
98+
config = BrandingConfig(template="builtin:nonexistent")
99+
with pytest.raises(RenderError, match="Builtin template not found"):
100+
resolve_branding(config, tmp_path)
101+
102+
def test_write_failure(self, tmp_path: Path) -> None:
103+
config = BrandingConfig()
104+
with (
105+
patch("reeln.core.branding.Path.write_text", side_effect=OSError("disk full")),
106+
pytest.raises(RenderError, match="Failed to write rendered branding"),
107+
):
108+
resolve_branding(config, tmp_path)

0 commit comments

Comments
 (0)