- "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
365 lines
13 KiB
Python
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
|