""" quality_gate.py - Validate output before uploading to YouTube. Runs quality checks on generated videos: - Audio loudness (target -14 LUFS) - Duration (Shorts ≤ 180s) - Resolution (720x1280 vertical) - Subtitle sync verification - Translation quality - File integrity Prevents bad videos from reaching the channel. """ import json import os import re import subprocess import threading from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional class QualityGate: """Video quality gate for AutoDub HerStory.""" def __init__(self, state=None, brain=None): self.state = state self.brain = brain self._lock = threading.Lock() # ================================================================ # Public API # ================================================================ def check_video(self, video_path: str, segments: List[Dict], voice_path: str, original_duration: float, visual_config: Optional[Dict] = None) -> Dict[str, Any]: """Run all quality checks on a generated video. Args: video_path: Path to the final composed video segments: List of segment dicts with timing info voice_path: Path to the Spanish voice track original_duration: Original video duration in seconds visual_config: Visual config dict (blur, subtitles) Returns: Quality report dict with pass/fail, score, and recommendations """ checks = [] # Critical checks (must pass) checks.append(self.check_file_integrity(video_path)) checks.append(self.check_duration(video_path)) checks.append(self.check_resolution(video_path)) # Quality checks (affect score) checks.append(self.check_audio_loudness(voice_path)) checks.append(self.check_subtitle_sync(segments, voice_path)) checks.append(self.check_translation_quality(segments)) if visual_config: checks.append(self.check_blur_coverage(video_path, visual_config)) # Calculate overall score check_scores = [c.get("score", 5.0) for c in checks if c.get("score") is not None] overall_score = sum(check_scores) / len(check_scores) if check_scores else 0.0 # Determine pass/fail critical_failed = any( c["passed"] is False and c.get("critical", True) for c in checks ) passed = not critical_failed and overall_score >= 5.0 # Recommendation if overall_score >= 7.0 and passed: recommendation = "publish" elif overall_score >= 5.0 and passed: recommendation = "fix" else: recommendation = "reject" # Collect fix suggestions fix_suggestions = [] for c in checks: if not c["passed"] and c.get("suggestion"): fix_suggestions.append(c["suggestion"]) report = { "passed": passed, "score": round(overall_score, 1), "checks": checks, "recommendation": recommendation, "fix_suggestions": fix_suggestions, "checked_at": datetime.now().isoformat(), } # Save to QA history self._save_qa_history(video_path, report) return report # ================================================================ # Individual Quality Checks # ================================================================ def check_audio_loudness(self, voice_path: str) -> Dict[str, Any]: """Check audio loudness using FFmpeg loudnorm filter. Target: -14 LUFS (YouTube standard). Warn if >3dB off. """ if not voice_path or not Path(voice_path).exists(): return { "name": "audio_loudness", "passed": False, "score": 0.0, "message": "Voice file not found", "critical": False, "suggestion": "Regenerate voice track", } try: # Run loudnorm analysis proc = subprocess.run([ "ffmpeg", "-i", voice_path, "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-", ], capture_output=True, text=True, timeout=30) # Parse loudnorm output (JSON is in stderr's last lines) output = proc.stderr json_match = re.search(r'\{[^}]*"input_i"[^}]*\}', output, re.DOTALL) if json_match: try: loudness_data = json.loads(json_match.group()) input_lufs = float(loudness_data.get("input_i", -14)) target_lufs = -14.0 deviation = abs(input_lufs - target_lufs) if deviation <= 1.0: score = 10.0 passed = True message = f"Loudness OK: {input_lufs:.1f} LUFS (target: {target_lufs})" elif deviation <= 3.0: score = 7.0 passed = True message = f"Loudness acceptable: {input_lufs:.1f} LUFS (target: {target_lufs}, off by {deviation:.1f}dB)" elif deviation <= 6.0: score = 4.0 passed = True message = f"Loudness off: {input_lufs:.1f} LUFS (off by {deviation:.1f}dB)" else: score = 2.0 passed = False message = f"Loudness way off: {input_lufs:.1f} LUFS (off by {deviation:.1f}dB)" return { "name": "audio_loudness", "passed": passed, "score": score, "message": message, "critical": False, "details": loudness_data, "suggestion": "Apply loudnorm filter: -af loudnorm=I=-14:TP=-1:LRA=11" if deviation > 3.0 else None, } except json.JSONDecodeError: pass # Fallback: simple volume analysis proc2 = subprocess.run([ "ffmpeg", "-i", voice_path, "-af", "volumedetect", "-f", "null", "-", ], capture_output=True, text=True, timeout=30) mean_vol_match = re.search(r'mean_volume:\s*([-\d.]+)\s*dB', proc2.stderr) if mean_vol_match: mean_vol = float(mean_vol_match.group(1)) return { "name": "audio_loudness", "passed": True, "score": 6.0, "message": f"Mean volume: {mean_vol:.1f} dB (loudnorm unavailable)", "critical": False, "details": {"mean_volume_db": mean_vol}, } return { "name": "audio_loudness", "passed": True, "score": 5.0, "message": "Could not analyze loudness", "critical": False, } except Exception as e: return { "name": "audio_loudness", "passed": True, "score": 5.0, "message": f"Loudness check error: {e}", "critical": False, } def check_duration(self, video_path: str, max_duration: float = 180.0) -> Dict[str, Any]: """Check that video is within YouTube Shorts duration limit (≤ 180s).""" duration = self._get_duration(video_path) if duration <= 0: return { "name": "duration", "passed": False, "score": 0.0, "message": "Could not determine video duration", "critical": True, "suggestion": "Check video file integrity", } if duration > max_duration: return { "name": "duration", "passed": False, "score": 1.0, "message": f"Too long: {duration:.1f}s (max {max_duration}s for Shorts)", "critical": True, "details": {"duration": duration, "max": max_duration}, "suggestion": "Speed up audio or trim video to fit within 180s", } # Good duration if duration <= max_duration * 0.9: score = 10.0 elif duration <= max_duration * 0.95: score = 8.0 else: score = 6.0 return { "name": "duration", "passed": True, "score": score, "message": f"Duration OK: {duration:.1f}s (max {max_duration}s)", "critical": True, "details": {"duration": round(duration, 1), "max": max_duration}, } def check_subtitle_sync(self, segments: List[Dict], voice_path: str) -> Dict[str, Any]: """Verify subtitle timing matches audio segments. Checks that segments don't overlap and have reasonable timing. """ if not segments: return { "name": "subtitle_sync", "passed": True, "score": 5.0, "message": "No segments to check", "critical": False, } issues = [] total_segments = len(segments) # Check for overlaps for i in range(1, len(segments)): prev_end = segments[i - 1].get("end", 0) curr_start = segments[i].get("start", 0) if curr_start < prev_end - 0.05: # 50ms tolerance issues.append(f"Segment {i} overlaps with previous by {prev_end - curr_start:.3f}s") # Check for empty text empty_count = sum(1 for s in segments if not s.get("text_es", "").strip()) if empty_count > 0: issues.append(f"{empty_count}/{total_segments} segments have empty Spanish text") # Check for extremely short segments (< 0.5s) short_count = sum(1 for s in segments if (s.get("end", 0) - s.get("start", 0)) < 0.5) if short_count > total_segments * 0.3: issues.append(f"{short_count}/{total_segments} segments are very short (<0.5s)") if not issues: return { "name": "subtitle_sync", "passed": True, "score": 9.0, "message": f"Subtitle sync OK ({total_segments} segments, no overlaps)", "critical": False, "details": {"segments": total_segments}, } elif len(issues) <= 2: return { "name": "subtitle_sync", "passed": True, "score": 6.0, "message": f"Minor sync issues: {'; '.join(issues)}", "critical": False, "details": {"issues": issues}, "suggestion": "Review segment timing and fix overlaps", } else: return { "name": "subtitle_sync", "passed": False, "score": 3.0, "message": f"Multiple sync issues: {'; '.join(issues[:5])}", "critical": False, "details": {"issues": issues}, "suggestion": "Re-transcribe and re-translate with better timing", } def check_translation_quality(self, segments: List[Dict]) -> Dict[str, Any]: """Basic translation quality validation. Checks: no empty segments, no obvious English text, reasonable length. """ if not segments: return { "name": "translation_quality", "passed": True, "score": 5.0, "message": "No segments to check", "critical": False, } issues = [] total = len(segments) # Check for empty translations empty = sum(1 for s in segments if not s.get("text_es", "").strip()) if empty > total * 0.2: issues.append(f"{empty}/{total} segments have empty translations") # Check for obvious English text (common English words in Spanish text) english_indicators = [" the ", " and ", " is ", " are ", " was ", " were ", " they ", " she ", " he "] english_count = 0 for s in segments: text_es = s.get("text_es", "").lower() if any(indicator in text_es for indicator in english_indicators): english_count += 1 if english_count > 2: issues.append(f"{english_count} segments may contain untranslated English text") # Check for very short translations (likely incomplete) very_short = sum(1 for s in segments if len(s.get("text_es", "").strip()) < 3 and s.get("text_es", "").strip()) if very_short > total * 0.3: issues.append(f"{very_short}/{total} segments have very short translations") # Use brain for quality check if available brain_score = None if self.brain and self.brain.is_ready(): try: sample_orig = " ".join(s.get("text", "") for s in segments[:5]) sample_trans = " ".join(s.get("text_es", "") for s in segments[:5]) validation = self.brain.validate_translation(sample_orig[:500], sample_trans[:500]) brain_score = validation.get("score", None) if not validation.get("approved", True): issues.append(f"Brain rejected translation: {validation.get('suggestions', '')[:100]}") except Exception: pass # Calculate score base_score = 8.0 if issues: base_score -= len(issues) * 1.5 if brain_score is not None: score = (base_score + brain_score) / 2 else: score = base_score passed = score >= 5.0 and len(issues) < 3 return { "name": "translation_quality", "passed": passed, "score": round(max(0, min(10, score)), 1), "message": f"Translation quality: {len(issues)} issues found" + (f", brain score: {brain_score}" if brain_score else ""), "critical": False, "details": {"issues": issues, "brain_score": brain_score, "empty_count": empty, "english_count": english_count}, "suggestion": "Re-translate with different parameters" if not passed else None, } def check_file_integrity(self, video_path: str) -> Dict[str, Any]: """Verify video file is valid and playable.""" if not video_path or not Path(video_path).exists(): return { "name": "file_integrity", "passed": False, "score": 0.0, "message": f"Video file not found: {video_path}", "critical": True, "suggestion": "Re-render the video", } # Check file size file_size = Path(video_path).stat().st_size if file_size < 10000: # Less than 10KB return { "name": "file_integrity", "passed": False, "score": 0.0, "message": f"Video file too small: {file_size} bytes", "critical": True, "suggestion": "Re-render the video - current file appears corrupt", } # Try ffprobe try: proc = subprocess.run([ "ffprobe", "-v", "error", "-show_format", "-show_streams", "-print_format", "json", video_path, ], capture_output=True, text=True, timeout=15) if proc.returncode != 0: return { "name": "file_integrity", "passed": False, "score": 1.0, "message": f"ffprobe failed: {proc.stderr[:200]}", "critical": True, "suggestion": "Re-render the video - file may be corrupt", } probe_data = json.loads(proc.stdout) streams = probe_data.get("streams", []) has_video = any(s.get("codec_type") == "video" for s in streams) has_audio = any(s.get("codec_type") == "audio" for s in streams) if not has_video: return { "name": "file_integrity", "passed": False, "score": 1.0, "message": "No video stream found", "critical": True, "suggestion": "Re-render the video", } if not has_audio: return { "name": "file_integrity", "passed": True, "score": 5.0, "message": "Video OK but NO audio stream (may be missing voice)", "critical": False, "suggestion": "Check voice track was added correctly", } return { "name": "file_integrity", "passed": True, "score": 10.0, "message": f"Video file OK ({file_size / 1024 / 1024:.1f} MB, {len(streams)} streams)", "critical": True, "details": {"size_mb": round(file_size / 1024 / 1024, 1), "streams": len(streams)}, } except Exception as e: return { "name": "file_integrity", "passed": True, # Don't fail if ffprobe isn't available "score": 5.0, "message": f"Cannot verify integrity: {e}", "critical": True, } def check_resolution(self, video_path: str) -> Dict[str, Any]: """Check video is 720x1280 (vertical Short format).""" try: proc = subprocess.run([ "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", video_path, ], capture_output=True, text=True, timeout=15) if proc.returncode != 0 or not proc.stdout.strip(): return { "name": "resolution", "passed": True, "score": 5.0, "message": "Could not check resolution", "critical": True, } parts = proc.stdout.strip().split(",") if len(parts) >= 2: width = int(parts[0]) height = int(parts[1]) # Expected: 720x1280 (vertical Short) if width == 720 and height == 1280: return { "name": "resolution", "passed": True, "score": 10.0, "message": f"Resolution OK: {width}x{height}", "critical": True, } elif height > width: # Vertical but different size return { "name": "resolution", "passed": True, "score": 7.0, "message": f"Vertical but not 720x1280: {width}x{height}", "critical": True, "details": {"width": width, "height": height}, "suggestion": f"Expected 720x1280, got {width}x{height}", } else: # Horizontal (bad for Shorts) return { "name": "resolution", "passed": False, "score": 2.0, "message": f"HORIZONTAL video: {width}x{height} (Shorts need vertical)", "critical": True, "details": {"width": width, "height": height}, "suggestion": "Re-render with vertical (portrait) format", } except Exception as e: return { "name": "resolution", "passed": True, "score": 5.0, "message": f"Resolution check error: {e}", "critical": True, } def check_blur_coverage(self, video_path: str, visual_config: Dict) -> Dict[str, Any]: """Verify blur region covers English subtitles area. Checks that visual_config has reasonable blur values. Full pixel verification would require rendering a frame. """ blur_x = visual_config.get("blur_x", 0) blur_y = visual_config.get("blur_y", 1030) blur_w = visual_config.get("blur_w", 720) blur_h = visual_config.get("blur_h", 140) blur_strength = visual_config.get("blur_strength", 20) issues = [] # Check blur region is in the lower portion of the video (where English subs usually are) if blur_y < 800: # Too high for subtitle coverage issues.append(f"Blur Y position ({blur_y}) seems too high for subtitle coverage") if blur_w < 400: # Too narrow issues.append(f"Blur width ({blur_w}) seems too narrow") if blur_h < 80: # Too short issues.append(f"Blur height ({blur_h}) seems too short") if blur_strength < 5: # Not enough blur issues.append(f"Blur strength ({blur_strength}) seems too low") if not issues: return { "name": "blur_coverage", "passed": True, "score": 9.0, "message": f"Blur config OK: ({blur_x},{blur_y},{blur_w},{blur_h}) strength={blur_strength}", "critical": False, "details": visual_config, } else: return { "name": "blur_coverage", "passed": True, "score": 5.0, "message": f"Blur config issues: {'; '.join(issues)}", "critical": False, "details": visual_config, "suggestion": "Adjust visual_config blur settings on dashboard", } # ================================================================ # Internal Methods # ================================================================ def _get_duration(self, path: str) -> float: """Get media file duration using ffprobe.""" try: proc = subprocess.run([ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path, ], capture_output=True, text=True, timeout=10) if proc.returncode == 0 and proc.stdout.strip(): return float(proc.stdout.strip()) except Exception: pass return 0.0 def _save_qa_history(self, video_path: str, report: Dict[str, Any]): """Save QA check to history.""" if not self.state or not hasattr(self.state, "_state"): return try: history = self.state._state.get("qa_history", []) entry = { "video_path": video_path, "passed": report["passed"], "score": report["score"], "recommendation": report["recommendation"], "checked_at": report["checked_at"], "issues_count": len(report.get("fix_suggestions", [])), } history.append(entry) # Keep last 100 entries if len(history) > 100: history = history[-100:] self.state._state["qa_history"] = history except Exception as e: print(f"[QA] Failed to save QA history: {e}")