chrysopedia/backend/pipeline/test_card_renderer.py
jlightner fa493e2640 feat: Built ffmpeg-based card renderer with concat demuxer pipeline and…
- "backend/pipeline/card_renderer.py"
- "backend/pipeline/shorts_generator.py"
- "backend/pipeline/stages.py"
- "backend/models.py"
- "alembic/versions/028_add_shorts_template.py"
- "backend/pipeline/test_card_renderer.py"

GSD-Task: S04/T02
2026-04-04 11:17:38 +00:00

365 lines
13 KiB
Python

"""Tests for card_renderer: ffmpeg card generation and concat pipeline.
Tests verify command construction, concat list file format, and template
config parsing — no actual ffmpeg execution required.
"""
from __future__ import annotations
import subprocess
import textwrap
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from pipeline.card_renderer import (
DEFAULT_ACCENT_COLOR,
DEFAULT_FONT_FAMILY,
DEFAULT_INTRO_DURATION,
DEFAULT_OUTRO_DURATION,
build_concat_list,
concat_segments,
parse_template_config,
render_card,
render_card_to_file,
)
# ── render_card tests ────────────────────────────────────────────────────────
class TestRenderCard:
"""Tests for render_card() ffmpeg command generation."""
def test_returns_list_of_strings(self):
cmd = render_card("Hello", 2.0, 1080, 1920)
assert isinstance(cmd, list)
assert all(isinstance(s, str) for s in cmd)
def test_contains_ffmpeg_and_lavfi(self):
cmd = render_card("Test", 3.0, 1080, 1920)
assert cmd[0] == "ffmpeg"
assert "-f" in cmd
lavfi_idx = cmd.index("-f")
assert cmd[lavfi_idx + 1] == "lavfi"
def test_contains_correct_dimensions_in_filtergraph(self):
cmd = render_card("Test", 2.0, 1920, 1080)
# The filtergraph is the arg after -i for lavfi
filtergraph = None
for i, arg in enumerate(cmd):
if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi":
filtergraph = cmd[i + 1]
break
assert filtergraph is not None
assert "s=1920x1080" in filtergraph
def test_contains_duration_in_filtergraph(self):
cmd = render_card("Test", 5.5, 1080, 1920)
filtergraph = None
for i, arg in enumerate(cmd):
if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi":
filtergraph = cmd[i + 1]
break
assert "d=5.5" in filtergraph
def test_contains_drawtext_with_text(self):
cmd = render_card("My Creator", 2.0, 1080, 1920)
filtergraph = None
for i, arg in enumerate(cmd):
if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi":
filtergraph = cmd[i + 1]
break
assert "drawtext=" in filtergraph
assert "My Creator" in filtergraph
def test_codec_settings(self):
cmd = render_card("Test", 2.0, 1080, 1920)
assert "-c:v" in cmd
assert "libx264" in cmd
assert "-c:a" in cmd
assert "aac" in cmd
def test_silent_audio_track(self):
"""Card includes anullsrc so concat with audio segments works."""
cmd = render_card("Test", 2.0, 1080, 1920)
# Should have a second -f lavfi -i anullsrc input
cmd_str = " ".join(cmd)
assert "anullsrc" in cmd_str
def test_rejects_zero_duration(self):
with pytest.raises(ValueError, match="positive"):
render_card("Test", 0, 1080, 1920)
def test_rejects_negative_duration(self):
with pytest.raises(ValueError, match="positive"):
render_card("Test", -1.0, 1080, 1920)
def test_rejects_zero_dimensions(self):
with pytest.raises(ValueError, match="positive"):
render_card("Test", 2.0, 0, 1920)
def test_custom_accent_color(self):
cmd = render_card("Test", 2.0, 1080, 1920, accent_color="#ff0000")
filtergraph = None
for i, arg in enumerate(cmd):
if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi":
filtergraph = cmd[i + 1]
break
assert "#ff0000" in filtergraph
def test_escapes_colons_in_text(self):
cmd = render_card("Hello: World", 2.0, 1080, 1920)
filtergraph = None
for i, arg in enumerate(cmd):
if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi":
filtergraph = cmd[i + 1]
break
# Colons should be escaped for ffmpeg
assert "Hello\\: World" in filtergraph
# ── render_card_to_file tests ────────────────────────────────────────────────
class TestRenderCardToFile:
"""Tests for render_card_to_file() — mocked ffmpeg execution."""
@patch("pipeline.card_renderer.subprocess.run")
def test_calls_ffmpeg_and_returns_path(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(returncode=0)
out = tmp_path / "card.mp4"
out.write_bytes(b"fake") # stat().st_size needs the file
result = render_card_to_file("Hi", 2.0, 1080, 1920, out)
assert result == out
mock_run.assert_called_once()
# Output path should be the last arg
call_args = mock_run.call_args[0][0]
assert call_args[-1] == str(out)
@patch("pipeline.card_renderer.subprocess.run")
def test_raises_on_ffmpeg_failure(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(
returncode=1,
stderr=b"error: something failed",
)
out = tmp_path / "card.mp4"
with pytest.raises(subprocess.CalledProcessError):
render_card_to_file("Hi", 2.0, 1080, 1920, out)
# ── build_concat_list tests ─────────────────────────────────────────────────
class TestBuildConcatList:
"""Tests for build_concat_list() file content."""
def test_writes_correct_format(self, tmp_path):
seg1 = tmp_path / "intro.mp4"
seg2 = tmp_path / "main.mp4"
seg1.touch()
seg2.touch()
list_file = tmp_path / "concat.txt"
result = build_concat_list([seg1, seg2], list_file)
assert result == list_file
content = list_file.read_text()
lines = content.strip().split("\n")
assert len(lines) == 2
assert lines[0] == f"file '{seg1.resolve()}'"
assert lines[1] == f"file '{seg2.resolve()}'"
def test_three_segments(self, tmp_path):
segs = [tmp_path / f"seg{i}.mp4" for i in range(3)]
for s in segs:
s.touch()
list_file = tmp_path / "list.txt"
build_concat_list(segs, list_file)
content = list_file.read_text()
lines = content.strip().split("\n")
assert len(lines) == 3
# ── concat_segments tests ────────────────────────────────────────────────────
class TestConcatSegments:
"""Tests for concat_segments() — mocked ffmpeg execution."""
@patch("pipeline.card_renderer.subprocess.run")
def test_calls_ffmpeg_concat_demuxer(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(returncode=0)
seg1 = tmp_path / "seg1.mp4"
seg2 = tmp_path / "seg2.mp4"
seg1.touch()
seg2.touch()
out = tmp_path / "output.mp4"
out.write_bytes(b"fakemp4")
result = concat_segments([seg1, seg2], out)
assert result == out
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert "concat" in call_args
assert "-safe" in call_args
assert "0" in call_args
assert "-c" in call_args
assert "copy" in call_args
def test_rejects_empty_segments(self):
with pytest.raises(ValueError, match="empty"):
concat_segments([], Path("/tmp/out.mp4"))
@patch("pipeline.card_renderer.subprocess.run")
def test_raises_on_ffmpeg_failure(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(
returncode=1, stderr=b"concat error",
)
seg1 = tmp_path / "s.mp4"
seg1.touch()
out = tmp_path / "out.mp4"
with pytest.raises(subprocess.CalledProcessError):
concat_segments([seg1], out)
# ── parse_template_config tests ──────────────────────────────────────────────
class TestParseTemplateConfig:
"""Tests for parse_template_config() defaults and overrides."""
def test_none_returns_all_defaults_disabled(self):
cfg = parse_template_config(None)
assert cfg["show_intro"] is False
assert cfg["show_outro"] is False
assert cfg["accent_color"] == DEFAULT_ACCENT_COLOR
assert cfg["font_family"] == DEFAULT_FONT_FAMILY
assert cfg["intro_duration"] == DEFAULT_INTRO_DURATION
assert cfg["outro_duration"] == DEFAULT_OUTRO_DURATION
def test_empty_dict_returns_defaults_disabled(self):
cfg = parse_template_config({})
assert cfg["show_intro"] is False
assert cfg["show_outro"] is False
def test_full_config_preserves_values(self):
raw = {
"show_intro": True,
"intro_text": "Welcome!",
"intro_duration": 3.0,
"show_outro": True,
"outro_text": "Bye!",
"outro_duration": 1.5,
"accent_color": "#ff0000",
"font_family": "Roboto",
}
cfg = parse_template_config(raw)
assert cfg["show_intro"] is True
assert cfg["intro_text"] == "Welcome!"
assert cfg["intro_duration"] == 3.0
assert cfg["show_outro"] is True
assert cfg["outro_text"] == "Bye!"
assert cfg["outro_duration"] == 1.5
assert cfg["accent_color"] == "#ff0000"
assert cfg["font_family"] == "Roboto"
def test_partial_config_fills_defaults(self):
raw = {"show_intro": True, "intro_text": "Hi"}
cfg = parse_template_config(raw)
assert cfg["show_intro"] is True
assert cfg["intro_text"] == "Hi"
assert cfg["intro_duration"] == DEFAULT_INTRO_DURATION
assert cfg["show_outro"] is False
assert cfg["outro_text"] == ""
assert cfg["accent_color"] == DEFAULT_ACCENT_COLOR
def test_truthy_coercion(self):
"""Non-bool truthy values should coerce to bool."""
cfg = parse_template_config({"show_intro": 1, "show_outro": 0})
assert cfg["show_intro"] is True
assert cfg["show_outro"] is False
def test_duration_coercion_from_int(self):
cfg = parse_template_config({"intro_duration": 5})
assert cfg["intro_duration"] == 5.0
assert isinstance(cfg["intro_duration"], float)
# ── extract_clip_with_template tests ─────────────────────────────────────────
class TestExtractClipWithTemplate:
"""Tests for the shorts_generator.extract_clip_with_template function."""
@patch("pipeline.shorts_generator.extract_clip")
def test_no_cards_delegates_to_extract_clip(self, mock_extract):
from pipeline.shorts_generator import extract_clip_with_template
extract_clip_with_template(
input_path=Path("/fake/input.mp4"),
output_path=Path("/fake/output.mp4"),
start_secs=10.0,
end_secs=20.0,
vf_filter="scale=1080:-2",
)
mock_extract.assert_called_once()
@patch("pipeline.card_renderer.concat_segments")
@patch("pipeline.shorts_generator.extract_clip")
def test_with_intro_concats_two_segments(self, mock_extract, mock_concat, tmp_path):
from pipeline.shorts_generator import extract_clip_with_template
intro = tmp_path / "intro.mp4"
intro.touch()
out = tmp_path / "final.mp4"
main_tmp = Path(str(out) + ".main.mp4")
# Create the main clip temp file so cleanup doesn't error
main_tmp.touch()
mock_concat.return_value = out
extract_clip_with_template(
input_path=Path("/fake/input.mp4"),
output_path=out,
start_secs=10.0,
end_secs=20.0,
vf_filter="scale=1080:-2",
intro_path=intro,
)
mock_extract.assert_called_once()
mock_concat.assert_called_once()
# Segments should be [intro, main_clip]
segments = mock_concat.call_args[1]["segments"]
assert len(segments) == 2
assert segments[0] == intro
@patch("pipeline.card_renderer.concat_segments")
@patch("pipeline.shorts_generator.extract_clip")
def test_with_intro_and_outro_concats_three_segments(
self, mock_extract, mock_concat, tmp_path,
):
from pipeline.shorts_generator import extract_clip_with_template
intro = tmp_path / "intro.mp4"
outro = tmp_path / "outro.mp4"
intro.touch()
outro.touch()
out = tmp_path / "final.mp4"
main_tmp = Path(str(out) + ".main.mp4")
main_tmp.touch()
mock_concat.return_value = out
extract_clip_with_template(
input_path=Path("/fake/input.mp4"),
output_path=out,
start_secs=10.0,
end_secs=20.0,
vf_filter="scale=1080:-2",
intro_path=intro,
outro_path=outro,
)
segments = mock_concat.call_args[1]["segments"]
assert len(segments) == 3
assert segments[0] == intro
assert segments[2] == outro