- 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>
392 lines
13 KiB
Python
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() |