video-processor/tests/fixtures/generate_synthetic_videos.py
Ryan Malloy 90508c417d Implement comprehensive test video suite with fixtures and integration
- Created comprehensive test video downloader (CC-licensed content)
- Built synthetic video generator for edge cases, codecs, patterns
- Added test suite manager with categorized test suites (smoke, basic, codecs, edge_cases, stress)
- Generated 108+ test videos covering various scenarios
- Updated integration tests to use comprehensive test suite
- Added comprehensive video processing integration tests
- Validated test suite structure and accessibility

Test Results:
- Generated 99 valid test videos (9 invalid by design)
- Successfully created edge cases: single frame, unusual resolutions, high FPS
- Multiple codec support: H.264, H.265, VP8, VP9, Theora, MPEG4
- Audio variations: mono/stereo, different sample rates, no audio, audio-only
- Visual patterns: SMPTE bars, RGB test, YUV test, checkerboard
- Motion tests: rotation, camera shake, scene changes
- Stress tests: high complexity scenes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 12:32:20 -06:00

392 lines
13 KiB
Python

"""
Generate synthetic test videos using ffmpeg for specific test scenarios.
Creates specific test scenarios that are hard to find in real videos.
"""
import subprocess
import math
from pathlib import Path
from typing import Optional, Tuple, List
import json
import random
class SyntheticVideoGenerator:
"""Generate synthetic test videos for specific test scenarios."""
def __init__(self, output_dir: Path):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
def generate_all(self):
"""Generate all synthetic test videos."""
print("🎥 Generating Synthetic Test Videos...")
# Edge cases
self.generate_edge_cases()
# Codec stress tests
self.generate_codec_tests()
# Audio tests
self.generate_audio_tests()
# Visual pattern tests
self.generate_pattern_tests()
# Motion tests
self.generate_motion_tests()
# Encoding stress tests
self.generate_stress_tests()
print("✅ Synthetic video generation complete!")
def generate_edge_cases(self):
"""Generate edge case test videos."""
edge_dir = self.output_dir / "edge_cases"
edge_dir.mkdir(exist_ok=True)
# Single frame video
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'color=c=blue:s=640x480:d=0.04',
'-vframes', '1',
str(edge_dir / 'single_frame.mp4')
])
print(" ✓ Generated: single_frame.mp4")
# Very long duration but static (low bitrate possible)
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'color=c=black:s=320x240:d=300', # 5 minutes
'-c:v', 'libx264',
'-crf', '51', # Very high compression
str(edge_dir / 'long_static.mp4')
])
print(" ✓ Generated: long_static.mp4")
# Extremely high FPS
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'testsrc2=s=640x480:r=120:d=2',
'-r', '120',
str(edge_dir / 'high_fps_120.mp4')
])
print(" ✓ Generated: high_fps_120.mp4")
# Unusual resolutions
resolutions = [
("16x16", "tiny_16x16.mp4"),
("100x100", "small_square.mp4"),
("1920x2", "line_horizontal.mp4"),
("2x1080", "line_vertical.mp4"),
("1337x999", "odd_dimensions.mp4"),
]
for resolution, filename in resolutions:
try:
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', f'testsrc2=s={resolution}:d=1',
str(edge_dir / filename)
])
print(f" ✓ Generated: {filename}")
except:
print(f" ⚠ Skipped: {filename} (resolution not supported)")
# Extreme aspect ratios
aspects = [
("3840x240", "ultra_wide_16_1.mp4"),
("240x3840", "ultra_tall_1_16.mp4"),
]
for spec, filename in aspects:
try:
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', f'testsrc2=s={spec}:d=2',
str(edge_dir / filename)
])
print(f" ✓ Generated: {filename}")
except:
print(f" ⚠ Skipped: {filename} (aspect ratio not supported)")
def generate_codec_tests(self):
"""Generate videos with various codecs and encoding parameters."""
codec_dir = self.output_dir / "codecs"
codec_dir.mkdir(exist_ok=True)
# H.264 profiles and levels
h264_tests = [
("baseline", "3.0", "h264_baseline_3_0.mp4"),
("main", "4.0", "h264_main_4_0.mp4"),
("high", "5.1", "h264_high_5_1.mp4"),
]
for profile, level, filename in h264_tests:
try:
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'testsrc2=s=1280x720:d=3',
'-c:v', 'libx264',
'-profile:v', profile,
'-level', level,
str(codec_dir / filename)
])
print(f" ✓ Generated: {filename}")
except:
print(f" ⚠ Skipped: {filename} (profile not supported)")
# Different codecs
codec_tests = [
("libx265", "h265_hevc.mp4", []),
("libvpx", "vp8.webm", []),
("libvpx-vp9", "vp9.webm", []),
("libtheora", "theora.ogv", []),
("mpeg4", "mpeg4.mp4", []),
]
for codec, filename, extra_opts in codec_tests:
try:
cmd = [
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'testsrc2=s=1280x720:d=2',
'-c:v', codec
]
cmd.extend(extra_opts)
cmd.append(str(codec_dir / filename))
self._run_ffmpeg(cmd)
print(f" ✓ Generated: {filename}")
except:
print(f" ⚠ Skipped: {filename} (codec not available)")
# Bit depth variations (if x265 available)
try:
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'testsrc2=s=1280x720:d=2',
'-c:v', 'libx265',
'-pix_fmt', 'yuv420p10le',
str(codec_dir / '10bit.mp4')
])
print(" ✓ Generated: 10bit.mp4")
except:
print(" ⚠ Skipped: 10bit.mp4")
def generate_audio_tests(self):
"""Generate videos with various audio configurations."""
audio_dir = self.output_dir / "audio"
audio_dir.mkdir(exist_ok=True)
# No audio stream
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'testsrc2=s=640x480:d=3',
'-an',
str(audio_dir / 'no_audio.mp4')
])
print(" ✓ Generated: no_audio.mp4")
# Various audio configurations
audio_configs = [
(1, 8000, "mono_8khz.mp4"),
(1, 22050, "mono_22khz.mp4"),
(2, 44100, "stereo_44khz.mp4"),
(2, 48000, "stereo_48khz.mp4"),
]
for channels, sample_rate, filename in audio_configs:
try:
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'testsrc2=s=640x480:d=2',
'-f', 'lavfi',
'-i', f'sine=frequency=440:sample_rate={sample_rate}:duration=2',
'-c:v', 'libx264',
'-c:a', 'aac',
'-ac', str(channels),
'-ar', str(sample_rate),
str(audio_dir / filename)
])
print(f" ✓ Generated: {filename}")
except:
print(f" ⚠ Skipped: {filename}")
# Audio-only file (no video stream)
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'sine=frequency=440:duration=5',
'-c:a', 'aac',
str(audio_dir / 'audio_only.mp4')
])
print(" ✓ Generated: audio_only.mp4")
def generate_pattern_tests(self):
"""Generate videos with specific visual patterns."""
pattern_dir = self.output_dir / "patterns"
pattern_dir.mkdir(exist_ok=True)
patterns = [
("smptebars", "smpte_bars.mp4"),
("rgbtestsrc", "rgb_test.mp4"),
("yuvtestsrc", "yuv_test.mp4"),
]
for pattern, filename in patterns:
try:
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', f'{pattern}=s=1280x720:d=3',
str(pattern_dir / filename)
])
print(f" ✓ Generated: {filename}")
except:
print(f" ⚠ Skipped: {filename}")
# Checkerboard pattern
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'nullsrc=s=1280x720:d=3',
'-vf', 'geq=lum=\'if(mod(floor(X/40)+floor(Y/40),2),255,0)\'',
str(pattern_dir / 'checkerboard.mp4')
])
print(" ✓ Generated: checkerboard.mp4")
def generate_motion_tests(self):
"""Generate videos with specific motion patterns."""
motion_dir = self.output_dir / "motion"
motion_dir.mkdir(exist_ok=True)
# Fast rotation motion
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'testsrc2=s=1280x720:r=30:d=3',
'-vf', 'rotate=PI*t',
str(motion_dir / 'fast_rotation.mp4')
])
print(" ✓ Generated: fast_rotation.mp4")
# Slow rotation motion
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'testsrc2=s=1280x720:r=30:d=3',
'-vf', 'rotate=PI*t/10',
str(motion_dir / 'slow_rotation.mp4')
])
print(" ✓ Generated: slow_rotation.mp4")
# Shake effect (simulated camera shake)
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'testsrc2=s=1280x720:r=30:d=3',
'-vf', 'crop=in_w-20:in_h-20:10*sin(t*10):10*cos(t*10)',
str(motion_dir / 'camera_shake.mp4')
])
print(" ✓ Generated: camera_shake.mp4")
# Scene changes
try:
self.create_scene_change_video(motion_dir / 'scene_changes.mp4')
print(" ✓ Generated: scene_changes.mp4")
except:
print(" ⚠ Skipped: scene_changes.mp4 (concat not supported)")
def generate_stress_tests(self):
"""Generate videos that stress test the encoder."""
stress_dir = self.output_dir / "stress"
stress_dir.mkdir(exist_ok=True)
# High complexity scene (mandelbrot fractal)
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'mandelbrot=s=1280x720:r=30',
'-t', '3',
str(stress_dir / 'high_complexity.mp4')
])
print(" ✓ Generated: high_complexity.mp4")
# Noise (hard to compress)
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', 'noise=alls=100:allf=t',
'-s', '1280x720',
'-t', '3',
str(stress_dir / 'noise_high.mp4')
])
print(" ✓ Generated: noise_high.mp4")
def create_scene_change_video(self, output_path: Path):
"""Create a video with multiple scene changes."""
colors = ['red', 'green', 'blue', 'yellow', 'magenta', 'cyan', 'white', 'black']
segments = []
for i, color in enumerate(colors):
segment_path = output_path.with_suffix(f'.seg{i}.mp4')
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'lavfi',
'-i', f'color=c={color}:s=640x480:d=0.5',
str(segment_path)
])
segments.append(str(segment_path))
# Concatenate
with open(output_path.with_suffix('.txt'), 'w') as f:
for seg in segments:
f.write(f"file '{seg}'\n")
self._run_ffmpeg([
'ffmpeg', '-y',
'-f', 'concat',
'-safe', '0',
'-i', str(output_path.with_suffix('.txt')),
'-c', 'copy',
str(output_path)
])
# Cleanup
for seg in segments:
Path(seg).unlink()
output_path.with_suffix('.txt').unlink()
def _run_ffmpeg(self, cmd: List[str]):
"""Run FFmpeg command safely."""
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return True
except subprocess.CalledProcessError as e:
# print(f"FFmpeg error: {e.stderr}")
raise e
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Generate synthetic test videos")
parser.add_argument("--output", "-o", default="tests/fixtures/videos/synthetic",
help="Output directory")
args = parser.parse_args()
generator = SyntheticVideoGenerator(Path(args.output))
generator.generate_all()