eac05dee30
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 35s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m53s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
AI Code Review / AI Code Review (pull_request) Failing after 1m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m5s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
CI/CD Pipeline / Validate - Style (push) Successful in 2m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Validate - Security (push) Successful in 6m51s
CI/CD Pipeline / Unit Tests (push) Failing after 7m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m3s
206 lines
7.9 KiB
Python
206 lines
7.9 KiB
Python
"""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
|