"""Tests for voice duration alignment feature. Tests the _align_clips_to_voice_duration method in UnifiedRenderService. """ from __future__ import annotations from pathlib import Path from unittest.mock import MagicMock, patch import pytest from video_processing.unified_render_service import RenderLayer, ResolvedClip, UnifiedRenderService class TestAlignClipsToVoiceDuration: """Test clip duration alignment to voice audio.""" def _make_clip( self, clip_id: str, duration: float, actual_duration: float = 0.0, playback_speed: float = 1.0, ) -> ResolvedClip: """Helper to create a ResolvedClip for testing.""" return ResolvedClip( clip_id=clip_id, asset_id=f"asset_{clip_id}", local_path=Path(f"/tmp/{clip_id}.mp4"), clip_type="main", order=0, duration=duration, actual_duration=actual_duration or duration, playback_speed=playback_speed, ) def _make_layer(self, role: str, clips: list[ResolvedClip]) -> RenderLayer: """Helper to create a RenderLayer for testing.""" return RenderLayer(role=role, clips=clips, z_index=0) def _make_service(self, voiceover_path: str | None = None) -> UnifiedRenderService: """Helper to create a mock UnifiedRenderService.""" plan = MagicMock() plan.id = "test_plan" plan.config = {} with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None): service = UnifiedRenderService.__new__(UnifiedRenderService) service.plan = plan service.voiceover_audio_path = voiceover_path service.transition_duration = 0.0 return service def test_no_voice_audio_no_adjustment(self): """No voice audio → no adjustment.""" service = self._make_service(voiceover_path=None) clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)] layers = [self._make_layer("main", clips)] service._align_clips_to_voice_duration(layers, voice_duration=0.0) # No change assert clips[0].duration == 10.0 assert clips[1].duration == 10.0 def test_ratio_within_5_percent_no_adjustment(self): """Ratio within ±5% → no adjustment.""" service = self._make_service() clips = [self._make_clip("c1", 10.0)] layers = [self._make_layer("main", clips)] # Total clips = 10s, voice = 10.3s → ratio = 1.03 (within 5%) service._align_clips_to_voice_duration(layers, voice_duration=10.3) assert clips[0].duration == 10.0 # Unchanged def test_ratio_less_than_1_trim_clips(self): """Ratio < 1 (clips too long) → trim clips proportionally.""" service = self._make_service() clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)] layers = [self._make_layer("main", clips)] # Total clips = 20s, voice = 15s → ratio = 0.75 service._align_clips_to_voice_duration(layers, voice_duration=15.0) # Each clip should be trimmed to 75% assert abs(clips[0].duration - 7.5) < 0.01 assert abs(clips[1].duration - 7.5) < 0.01 def test_ratio_greater_than_1_slowdown_clips(self): """Ratio > 1 (clips too short) → slow down clips.""" service = self._make_service() clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)] layers = [self._make_layer("main", clips)] # Total clips = 20s, voice = 25s → ratio = 1.25 service._align_clips_to_voice_duration(layers, voice_duration=25.0) # Each clip's speed should be reduced: 1.0 / 1.25 = 0.8 assert abs(clips[0].playback_speed - 0.8) < 0.01 assert abs(clips[1].playback_speed - 0.8) < 0.01 def test_speed_lower_bound_025(self): """Playback speed should not go below 0.25x.""" service = self._make_service() clips = [self._make_clip("c1", 5.0)] layers = [self._make_layer("main", clips)] # Total clips = 5s, voice = 50s → ratio = 10.0 # Speed would be 1.0 / 10 = 0.1, but should be clamped to 0.25 service._align_clips_to_voice_duration(layers, voice_duration=50.0) assert clips[0].playback_speed == 0.25 def test_only_video_layers_adjusted(self): """Only main/broll/background layers are adjusted, not audio.""" service = self._make_service() video_clips = [self._make_clip("v1", 10.0)] audio_clips = [self._make_clip("a1", 10.0)] layers = [ self._make_layer("main", video_clips), self._make_layer("audio", audio_clips), ] # ratio = 0.5 → should trim video but not audio service._align_clips_to_voice_duration(layers, voice_duration=5.0) assert abs(video_clips[0].duration - 5.0) < 0.01 # Trimmed assert audio_clips[0].duration == 10.0 # Unchanged def test_multiple_video_layers_all_adjusted(self): """All video layers (main, broll, background) are adjusted.""" service = self._make_service() main_clips = [self._make_clip("m1", 10.0)] broll_clips = [self._make_clip("b1", 10.0)] bg_clips = [self._make_clip("bg1", 10.0)] layers = [ self._make_layer("main", main_clips), self._make_layer("broll", broll_clips), self._make_layer("background", bg_clips), ] # Total video = 30s, voice = 15s → ratio = 0.5 service._align_clips_to_voice_duration(layers, voice_duration=15.0) # All should be trimmed to 50% assert abs(main_clips[0].duration - 5.0) < 0.01 assert abs(broll_clips[0].duration - 5.0) < 0.01 assert abs(bg_clips[0].duration - 5.0) < 0.01 def test_trim_config_also_adjusted(self): """When clip has trim_config, it should also be adjusted.""" from video_processing.trim_engine import TrimConfig service = self._make_service() clip = self._make_clip("c1", 10.0) clip.trim_config = TrimConfig(start_time=0.0, duration=10.0) layers = [self._make_layer("main", [clip])] # ratio = 0.5 service._align_clips_to_voice_duration(layers, voice_duration=5.0) assert abs(clip.duration - 5.0) < 0.01 assert clip.trim_config is not None assert abs(clip.trim_config.duration - 5.0) < 0.01 class TestGetVoiceAudioDuration: """Test voice audio duration probing.""" def test_no_voiceover_path_returns_zero(self): """No voiceover path → return 0.""" with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None): service = UnifiedRenderService.__new__(UnifiedRenderService) service.voiceover_audio_path = None assert service._get_voice_audio_duration() == 0.0 def test_nonexistent_file_returns_zero(self): """Nonexistent file → return 0.""" with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None): service = UnifiedRenderService.__new__(UnifiedRenderService) service.voiceover_audio_path = "/nonexistent/path.mp3" assert service._get_voice_audio_duration() == 0.0 @patch("video_processing.unified_render_service.probe_duration") @patch("video_processing.unified_render_service.Path.exists", return_value=True) @patch("video_processing.unified_render_service.Path.stat") def test_probes_duration_from_file(self, mock_stat, mock_exists, mock_probe): """Valid file → probe duration.""" mock_stat.return_value.st_size = 1000 # Non-empty file mock_probe.return_value = 42.5 with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None): service = UnifiedRenderService.__new__(UnifiedRenderService) service.voiceover_audio_path = "/tmp/voice.mp3" assert service._get_voice_audio_duration() == 42.5